gigafibre-fsm/services/targo-hub/lib/legacy-dispatch-sync.js
louispaulb 14530787bb consolidate uncommitted multi-session work (NL staff-agent, user avatars/profil, HelpHint, ticket detail modules, skill icons, hub agent/payments/sync)
Lot de travaux accumulés non commités (plusieurs sessions), vérifiés : hub `node --check`
tout OK + build SPA propre.
- ops : AssignmentField, CommandPalette, IssueDetail/DetailModal + detail-sections/modules,
  UserAvatar/useAvatar, UserProfileDialog/EmployeeEditDialog, HelpHint, useSkillIcons,
  pages (Dashboard/Equipe/Clients/Tickets/Reports/Settings/Evaluations/LegacySync…),
  usePermissions/useUserGroups/useDetailModal, MainLayout, TicketStatusControl
- hub : staff-agent.js (couche commandes NL), agent.js/agent-tools.json/voice-agent.js,
  avatars.js, conversation.js, payments.js, sync-orchestrator.js, auth.js, campaigns.js,
  legacy-dispatch-sync.js, server.js

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 20:43:50 -04:00

2151 lines
174 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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.
*
* LIEN FICHE CLIENT : chaque import (re)lie aussi l'Issue ERPNext sous-jacente (legacy_ticket_id) à son
* `customer` → le ticket legacy remonte dans la fiche client (ClientDetailPage). Rattrapage du backlog :
* GET/POST /dispatch/legacy-sync/backfill-issues (dry-run GET · applique POST). Kill-switch LEGACY_DISPATCH_LINK_ISSUES=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, parseBody } = 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 muni = require('./municipality') // résolveur de municipalités QC (centre-ville en dernier recours)
const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
const audit = require('./dispatch-audit') // journal d'audit d'assignation (append-only JSONL)
const fs = require('fs')
// Décodeur d'entités HTML UNIQUE (osTicket stocke les sujets encodés : &#039; &eacute; &amp; …). Appliqué à l'ÉCRITURE
// du sujet des jobs → la donnée stockée est propre partout (Dispatch, Planification, app terrain, rapports), pas de
// rustine d'affichage par surface. 2 passes = gère le double-encodage (&amp;#039;). Remplace 3 closures `decode` qui
// divergeaient (l'une gérait &quot;, pas les autres) — source exacte des « codes HTML par endroits ».
const NAMED_ENT = { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'", nbsp: ' ', eacute: 'é', egrave: 'è', ecirc: 'ê', euml: 'ë', agrave: 'à', acirc: 'â', agrave2: 'à', ccedil: 'ç', ugrave: 'ù', ucirc: 'û', uuml: 'ü', icirc: 'î', iuml: 'ï', ocirc: 'ô', ouml: 'ö', auml: 'ä', laquo: '«', raquo: '»', hellip: '…', rsquo: '', lsquo: '', ldquo: '“', rdquo: '”', ndash: '', mdash: '—', deg: '°', euro: '€', copy: '©', reg: '®', trade: '™' }
function decodeEntities (s) {
let v = String(s == null ? '' : s)
if (v.indexOf('&') < 0) return v
for (let i = 0; i < 2 && v.indexOf('&') >= 0; i++) {
const nv = v
.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(+n) } catch (e) { return '' } })
.replace(/&([a-z]+\d*);/gi, (m, name) => (Object.prototype.hasOwnProperty.call(NAMED_ENT, name.toLowerCase()) ? NAMED_ENT[name.toLowerCase()] : m))
if (nv === v) break
v = nv
}
return v
}
// 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
}
// Poster une note/réponse au fil d'un ticket legacy via ops_reassign.php action=post. public=false (défaut) = note INTERNE (jamais au client) · true = visible.
async function postTicketLegacy (ticketId, msg, isPublic, actorEmail, actorName) {
if (!ticketId || !String(msg || '').trim()) return { ok: false, error: 'ticket + msg requis' }
const actorStaff = await resolveActorStaff(actorEmail)
// Attribution : si l'acteur a un compte staff osTicket (courriel reconnu) → la note porte SON nom automatiquement.
// Sinon (ex. tech terrain sans compte legacy) on identifie l'auteur dans le corps pour ne PAS afficher « Tech Targo ».
let body = String(msg)
if (!actorStaff && actorName) body = actorName + ' :\n' + body
const w = await legacyWrite({ action: 'post', ticket_id: ticketId, msg: body, public: isPublic ? 1 : '', actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
return (w && w.data) || { ok: false, error: 'no response' }
}
// 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 }))
if (!(r && r.ok === false)) audit.record({ job, ticket: String(j.legacy_ticket_id || ''), event: 'returned-to-pool', from: j.assigned_tech || '', actor: actorEmail || 'ops', source: 'ops-return' })
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
}
// Cache de géocodage PERSISTANT : chaque adresse n'est géocodée (RQA) qu'UNE seule fois, puis conservée sur disque
// → survit aux redémarrages du hub (sinon le cache mémoire repartait à zéro à chaque restart = rafales RQA répétées,
// d'où les « statement timeout » en navigation). On mémorise aussi les ÉCHECS (res=null) → jamais de re-RQA d'une
// adresse non résoluble. (Idée « trouver la position une fois et conserver ».)
const GEO_CACHE_FILE = '/app/data/geocode-cache.json'
const _geoCache = (() => { try { return new Map(Object.entries(JSON.parse(fs.readFileSync(GEO_CACHE_FILE, 'utf8')))) } catch { return new Map() } })()
let _geoSaveT = null
function _saveGeoCache () { // écriture debouncée (regroupe les rafales)
clearTimeout(_geoSaveT)
_geoSaveT = setTimeout(() => { try { fs.writeFileSync(GEO_CACHE_FILE, JSON.stringify(Object.fromEntries(_geoCache))) } catch (e) { log('geocode-cache save:', e.message) } }, 2000)
}
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); _saveGeoCache() // mémorise (succès OU échec) + persiste sur disque → géocodé une seule fois
return res
}
// CONTINU — écrit la coord dans le Service Location ERPNext (magasin canonique du GPS), via PG direct (addrdb = même
// base Postgres que rqa_addresses), donc rapide et SANS passer par les workers Frappe (≠ la rafale qui plantait).
// N'écrit QUE les SL du client (par legacy_account_id) SANS coords (jamais d'écrasement) dont la ville correspond.
// La sync F→ERPNext ne touche pas aux coords → durable. Best-effort (self-catch) : n'impacte jamais le window-load.
// Statut 'review' = précision MOYENNE (géocodage auto) : surfacé dans address-conformity pour vérif humaine, et une
// source PLUS PRÉCISE ('validated' = GPS manuel OU futur import GIS) peut la RÉÉCRIRE (préséance review < validated).
async function persistGeocodeToSL (accountId, lat, lon, city) {
const acct = Number(accountId) // tabCustomer.legacy_account_id = INTEGER (passer un nombre, pas un texte)
if (!Number.isFinite(acct) || acct <= 0 || !(Math.abs(Number(lat)) > 0.01) || !addrdb.pool) return
try {
const p = addrdb.pool(); if (!p) return
await p.query(
`UPDATE "tabService Location" sl SET latitude=$2, longitude=$3, address_validation_status='review', modified=NOW()
WHERE sl.customer IN (SELECT name FROM "tabCustomer" WHERE legacy_account_id=$1)
AND (sl.latitude IS NULL OR sl.latitude=0 OR ABS(sl.latitude)<1)
AND ($4='' OR lower(unaccent(coalesce(sl.city,''))) = lower(unaccent($4)))`,
[acct, Number(lat), Number(lon), city || ''])
} catch (e) { log('persistGeocodeToSL:', e.message) }
}
// 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()
// Géocodeur OSM / Nominatim (GRATUIT — remplace Mapbox dans le repli d'ingest). Politique OSM : User-Agent requis, volume faible
// (seuls les jobs non résolus par l'infra/RQA y arrivent), caché. Borné au territoire ; la validation fine (proximité code postal) se fait à l'appel.
const _osmCache = new Map()
async function geocodeNominatim (addressLine, city, postalCode) {
if (!addressLine) return null
const key = norm([addressLine, city, postalCode].filter(Boolean).join('|'))
if (_osmCache.has(key)) return _osmCache.get(key)
let res = null
try {
const q = [addressLine, city, 'Québec', 'Canada'].filter(Boolean).join(', ')
const r = await httpRequest('https://nominatim.openstreetmap.org', '/search?format=json&limit=1&countrycodes=ca&q=' + encodeURIComponent(q), { headers: { 'User-Agent': 'TargoFSM/1.0 (dispatch geocode; louis@targo.ca)', 'Accept-Language': 'fr-CA' }, timeout: 12000 })
const f = Array.isArray(r && r.data) && r.data[0]
if (f && f.lat && f.lon) { const c = coord(+f.lat, +f.lon); if (c && inTerritory(c.lat, c.lon)) res = c }
} catch (e) { log('geocodeNominatim error:', e.message) }
_osmCache.set(key, res); return res
}
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
}
// Géocodage INVERSE via les PINS DE TERRAIN (table `fibre`, MySQL direct) = la SOURCE DE VÉRITÉ géoloc :
// coords validées sur le terrain (pin posé sur la maison), plus précises que le centroïde/rooftop RQA.
// ~16k lignes → un scan bbox + haversine est trivial (pas d'index requis). Retourne null si rien à proximité.
async function reverseNearestFibre (lat, lon) {
const p = pool(); if (!p) return null
if (!isFinite(lat) || !isFinite(lon)) return null
const dLat = 0.006, dLon = 0.006 / Math.max(0.2, Math.cos(lat * Math.PI / 180))
try {
const [rows] = await p.query(
`SELECT terrain, rue, ville, zip, latitude, longitude, placemarks_id,
(6371000*2*ASIN(SQRT(POWER(SIN(RADIANS(?-latitude)/2),2)+COS(RADIANS(?))*COS(RADIANS(latitude))*POWER(SIN(RADIANS(?-longitude)/2),2)))) AS dist
FROM fibre
WHERE latitude BETWEEN ?-? AND ?+? AND longitude BETWEEN ?-? AND ?+?
ORDER BY dist ASC LIMIT 1`,
[lat, lat, lon, lat, dLat, lat, dLat, lon, dLon, lon, dLon])
const r = rows && rows[0]
if (!r) return null
const line1 = [r.terrain, r.rue].filter(Boolean).join(' ').trim()
const address = [line1, r.ville, r.zip].filter(Boolean).join(', ')
return { address, latitude: +r.latitude, longitude: +r.longitude, ville: r.ville || '', code_postal: r.zip || '', distance_m: Math.round(r.dist), placemarks_id: r.placemarks_id || null, source: 'infra_fibre' }
} catch (e) { log('reverseNearestFibre:', e.message); return null }
}
// 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(/&nbsp;/gi, ' ').replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>')
.replace(/&#0*39;|&#x27;|&apos;/gi, "'").replace(/&quot;/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')
// PARENT/CHILD legacy : un ticket ENFANT (`t.parent` > 0) porte souvent account_id/delivery_id = 0 — le compte
// et l'adresse vivent sur le ticket PARENT. On résout donc le compte/adresse EFFECTIFS via COALESCE(propre, parent)
// (jointure `pt`). Sans ça, l'enfant ne se rattache à AUCUN client → n'apparaît pas dans la fiche (bug signalé).
// Le contact (a.*), l'adresse de service (dv.*) et resolveOrCreateCustomer (via l'alias account_id) utilisent tous
// l'account effectif → aucun changement requis dans buildJob. Un seul niveau de remontée (parent direct).
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,
COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) AS account_id,
COALESCE(NULLIF(t.delivery_id, 0), NULLIF(pt.delivery_id, 0)) AS delivery_id,
t.parent, t.waiting_for, t.participant, t.followed_by, t.important,
t.date_create, t.last_update,
a.first_name, a.last_name, a.company, a.email, a.cell, a.tel_home, a.address1, a.address2, a.city, a.state, a.zip,
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.placemarks_id AS dv_pmid, 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 pt ON pt.id = t.parent
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
LEFT JOIN account a ON a.id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0))
LEFT JOIN delivery dv ON dv.id = COALESCE(
NULLIF(t.delivery_id, 0),
NULLIF(pt.delivery_id, 0),
(SELECT d2.id FROM delivery d2 WHERE COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) > 0 AND d2.account_id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) 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 COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) > 0 AND d3.account_id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) 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()
let _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0 } // observabilité : comptes/SL créés + Issue (re)liées au client pendant le run (récits dans le résumé)
function resetCaches () { _custCache = new Map(); _slCache = new Map(); _createStats = { customers: 0, service_locations: 0, issues_linked: 0, issues_created: 0 } }
// Auto-création des comptes manquants pendant l'import (par défaut ON — désactivable via LEGACY_DISPATCH_CREATE_CUSTOMERS=off).
// Un ticket legacy dont le compte F n'existe pas encore côté ERPNext (ex. « Robert Usereau » dispatché mais introuvable dans OPS)
// crée le Customer (chemin canonique legacy-sync) + une Service Location depuis l'adresse de service → la fiche devient trouvable et liable.
const CREATE_MISSING = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_CREATE_CUSTOMERS || ''))
// Lien Issue↔client : l'Issue ERPNext sous-jacente (créée par le cron `migrate_tickets.py` à 4h30) DOIT porter `customer`
// pour que le ticket legacy remonte dans la fiche client — ClientDetailPage liste les Issue filtrées par `customer`
// (useClientData.loadTickets). Son `customer` reste vide quand le Customer a été créé APRÈS la migration (par ce pont,
// resolveOrCreateCustomer) : au moment du migrate, cust_map ne contenait pas encore le compte → Issue.customer = NULL.
// On (re)lie donc l'Issue ici ; si elle manque encore (ticket plus récent que la dernière passe migrate), on la CRÉE
// (minimale). Idempotent : migrate_tickets.py skippe tout legacy_ticket_id déjà présent → aucun doublon. Kill-switch : LEGACY_DISPATCH_LINK_ISSUES=off.
const LINK_ISSUES = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_LINK_ISSUES || ''))
const ISSUE_COMPANY = process.env.ERP_COMPANY || 'TARGO' // aligné sur migrate_tickets.py (COMPANY = "TARGO")
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]
}
// Résout le Customer par legacy_account_id ; s'il MANQUE et qu'on n'est pas en dry-run, le CRÉE via le chemin canonique
// (legacy-sync.createCustomersByIds → mapAccount depuis le compte F : nom/type/groupe/courriel/mobile). Idempotent (re-run = 0 doublon).
async function resolveOrCreateCustomer (accountId, dryRun) {
let c = await resolveCustomer(accountId)
if (c || !accountId || dryRun || !CREATE_MISSING) return c
try {
const ls = require('./legacy-sync') // lazy require (pas de cycle : legacy-sync ne require pas ce module)
const r = await ls.createCustomersByIds({ ids: [Number(accountId)], confirm: 'F-WINS', limit: 1 })
if (r && r.created) { _custCache.delete(String(accountId)); c = await resolveCustomer(accountId); if (c) _createStats.customers++ }
} catch (e) { log('resolveOrCreateCustomer: échec création compte ' + accountId + ' — ' + (e && e.message)) }
return c
}
// Crée une Service Location pour un client dépourvu d'adresse de service en ERPNext, depuis l'adresse legacy (delivery > facturation).
// Best-effort : si le doctype exige des champs qu'on ne fournit pas, erp.create renvoie {ok:false} → on log et on continue (le job reste lié au Customer).
async function createServiceLocation (custName, { line, city, zip, lat, lon, deliveryId } = {}, dryRun, { force = false } = {}) {
if (!custName || dryRun || (!CREATE_MISSING && !force)) return null // `force` = appel EXPLICITE (ex. backfill one-time) → non soumis au kill-switch du chemin chaud
const addrLine = String(line || '').trim()
if (!addrLine) return null
const doc = { customer: custName, address_line: addrLine.slice(0, 140), address_validation_status: 'pending' } // valeurs permises : pending/validated/manual/unmatched (PAS 'review')
if (city) doc.city = String(city).slice(0, 140)
const co = coord(lat, lon); if (co) { doc.latitude = co.lat; doc.longitude = co.lon }
if (deliveryId) doc.legacy_delivery_id = String(deliveryId)
try {
const r = await erp.create('Service Location', doc)
if (r && r.ok && r.name) { _slCache.delete(custName); _createStats.service_locations++; return { name: r.name, address_line: doc.address_line, city: doc.city || '', latitude: doc.latitude, longitude: doc.longitude } }
log('createServiceLocation: échec ' + custName + ' — ' + ((r && r.error) || 'create'))
} catch (e) { log('createServiceLocation: exception ' + custName + ' — ' + (e && e.message)) }
return null
}
// Construit le payload Dispatch Job à partir d'un ticket legacy (+ infos de matching).
// VALIDATION PAR TÉLÉPHONE : un ticket sans compte (ex. « …Message vocal de 15143182129 ») peut être relié au client via le
// numéro inscrit dans le sujet. SÛR uniquement si le téléphone matche UN SEUL compte AVEC UNE SEULE adresse de service
// (sinon ambigu → on ne devine pas). → { account_id, customer_name, dv:{address1,city,zip,lat,lon,pmid} } ou null.
function _extractPhone (s) { const m = String(s || '').match(/\b(1?\d{10})\b/); if (!m) return null; let d = m[1]; if (d.length === 11 && d[0] === '1') d = d.slice(1); return /^[2-9]\d{9}$/.test(d) ? d : null }
const _PHONE_COLS = ['tel_home', 'cell', 'tel_office']
async function accountByPhone (p, subject) {
const ph = _extractPhone(subject); if (!ph || !p) return null
const strip = (c) => `REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(${c},'-',''),' ',''),'(',''),')',''),'.',''),'+','')`
const where = _PHONE_COLS.map(c => strip(c) + ' LIKE ?').join(' OR ')
let accs; try { [accs] = await p.query(`SELECT id, first_name, last_name, company FROM account WHERE ${where} LIMIT 3`, _PHONE_COLS.map(() => '%' + ph)) } catch (e) { return null }
if (accs.length !== 1) return null // compte UNIQUE seulement
const a = accs[0]
let dvs; try { [dvs] = await p.query('SELECT id, address1, city, zip, latitude lat, longitude lon, placemarks_id pmid FROM delivery WHERE account_id=? ORDER BY id DESC', [a.id]) } catch (e) { return null }
if (dvs.length !== 1) return null // UNE SEULE adresse de service
return { account_id: a.id, customer_name: [a.first_name, a.last_name].filter(Boolean).join(' ') || a.company || '', dv: dvs[0] }
}
// Dernier recours pour les tickets TERRAIN sans compte/adresse (ex. messages vocaux) : trouver la VILLE mentionnée dans le
// sujet (résolution PAR SEGMENT — `resolveSync` sur le sujet entier échoue à cause des tokens parasites) puis géocoder au
// CENTRE de la ville (centroïde RQA). Pin approximatif « secteur » pour aider le dispatch. → {lat,lon,ville} ou null. Caché.
let _muniCentroid = {}
async function townCenterFromSubject (subject) {
let r = null
try {
await muni.load()
const segs = String(subject || '').split(/\s*[-|:·•/]\s*/).map(s => s.trim()).filter(s => s && s.length >= 3 && !/^\d/.test(s))
for (const s of segs) { const m = muni.resolveSync(s); if (m.matched && !m.ambiguous && m.score >= 1) { r = m; break } } // 1) match exact d'un segment
if (!r) for (const s of segs) { const m = muni.resolveSync(s); if (m.matched && !m.ambiguous) { r = m; break } } // 2) sinon flou non ambigu
} catch (e) { return null }
if (!r) return null
if (r.id in _muniCentroid) return _muniCentroid[r.id] ? { ..._muniCentroid[r.id] } : null
const ville = String(r.canonical || '').split(/\s+/).map(w => w === 'ST' ? 'Saint' : w === 'STE' ? 'Sainte' : w).join('-') // « ST ANICET » → « Saint-ANICET » (RQA stocke « Saint-… »)
let out = null
try {
const pg = addrdb.pool()
let q = await pg.query('SELECT round(avg(latitude)::numeric,6) lat, round(avg(longitude)::numeric,6) lon, count(*) n FROM rqa_addresses WHERE ville ILIKE $1', [ville])
if (!(q.rows[0] && +q.rows[0].n > 0)) { // repli : token distinctif (le plus long, hors Saint/Sainte)
const tok = ville.split('-').filter(w => !/^saints?e?$/i.test(w)).sort((a, b) => b.length - a.length)[0]
if (tok && tok.length >= 4) q = await pg.query('SELECT round(avg(latitude)::numeric,6) lat, round(avg(longitude)::numeric,6) lon, count(*) n FROM rqa_addresses WHERE ville ILIKE $1', ['%' + tok + '%'])
}
const row = q.rows[0]
if (row && +row.n > 0 && _inQC(row.lat, row.lon)) out = { lat: +row.lat, lon: +row.lon, ville: r.canonical }
} catch (e) { out = null }
_muniCentroid[r.id] = out
return out ? { ...out } : null
}
async function buildJob (t, { dryRun = false } = {}) {
const cust = await resolveOrCreateCustomer(t.account_id, dryRun) // crée le compte s'il manque (sauf dry-run)
let 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
// Le client existe (ou vient d'être créé) mais n'a AUCUNE Service Location → on la crée depuis l'adresse de service legacy
// (delivery de préférence, sinon facturation) pour que la fiche montre l'adresse et que le job pointe vers un lieu réel.
if (cust && !sl && CREATE_MISSING && !dryRun) {
const useSvc = !!(t.dv_addr)
sl = await createServiceLocation(cust.name, {
line: useSvc ? t.dv_addr : (t.address1 || svcAddr || billAddr),
city: useSvc ? t.dv_city : t.city,
zip: useSvc ? t.dv_zip : t.zip,
lat: t.dv_lat, lon: t.dv_lon, deliveryId: t.delivery_id || t.dv_id || null,
}, dryRun)
}
let subject = decodeEntities(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) : '')
// Bloc contact STRUCTURÉ depuis le compte F (fiable) — PRÉSERVE l'identité (nom/courriel/tél/adresse) même si le 1ᵉʳ
// message du ticket ne la contient pas. Labels lisibles par /conversations/resolve-ref (recherche fiche par n° de réf.).
// À garder tant que la création de tickets n'est pas migrée dans OPS (le sync legacy est la seule source de cette info).
const cphone = String(t.cell || t.tel_home || '').trim()
const contactHdr = [cname && ('Nom: ' + cname), t.email && ('Email: ' + String(t.email).trim()), cphone && ('Téléphone: ' + cphone), addr && ('Adresse: ' + addr)].filter(Boolean).join(' ')
const detail = [dateHdr, contactHdr, stripHtml(t.first_msg)].filter(Boolean).join('\n\n')
if (detail) payload.legacy_detail = detail // dates + contact structuré + description → visible Ops + parsé par resolve-ref
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 : l'adresse de service est un terrain de camping (signal = sujet/ville/adresse de service). Le résolveur préfère
// alors le LOT précis (placemarks.nom) puis le centroïde camping ; les coords delivery de camping (souvent erronées) en dernier.
const camp = campingFor(await getCampings(), [t.subject, t.dv_city, t.dv_addr])
// ALGORITHME DÉVELOPPÉ (le plus autoritaire) : placemarks_id frais / delivery / fibre rue-désambiguïsée / campings par nom de lot.
{ const dev = await resolveDevCoords(pool(), { pmid: t.dv_pmid, dlat: t.dv_lat, dlon: t.dv_lon, address: svcAddr || billAddr, camp }); if (dev) { payload.latitude = dev.lat; payload.longitude = dev.lon; coordSrc = dev.src } }
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 rien d'autre
}
if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA → OSM (validé) → CENTRE DU CODE POSTAL (borné)
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) // 1) RQA exact (autoritaire)
if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'rqa_geocode' }
else {
// Centre du code postal (moyenne RQA de la zone) = repli BORNÉ + garde-fou : un géocodeur externe qui tombe dans la MAUVAISE
// ville (ex. « Rue Elsie » → Valleyfield) est rejeté s'il est à > 10 km du centre du code postal.
const ctr = await geocodeCentroid(zip, ci)
const osm = await geocodeNominatim(line, ci, zip) // 2) OSM/OpenStreetMap (rue connue absente du RQA)
if (osm && (!ctr || _haversineM(osm.lat, osm.lon, ctr.lat, ctr.lon) <= 10000)) { payload.latitude = osm.lat; payload.longitude = osm.lon; coordSrc = 'osm_geocode' }
else if (ctr) { payload.latitude = ctr.lat; payload.longitude = ctr.lon; coordSrc = 'postal_centroid' } // 3) repli : jamais la mauvaise ville
}
}
if (!coordSrc) { // sans compte : retrouver le client par le TÉLÉPHONE du sujet (compte UNIQUE + 1 seule adresse) → adresse PRÉCISE + lien client
const pm = await accountByPhone(pool(), t.subject)
if (pm) {
const fa = [pm.dv.address1, pm.dv.city, pm.dv.zip].filter(Boolean).join(', ')
if (!payload.address && fa) payload.address = fa.slice(0, 140)
if (!payload.customer) { try { const c = await resolveOrCreateCustomer(pm.account_id, dryRun); if (c) payload.customer = c.name } catch (e) {} } // relie (ou crée) le compte OPS
const dev = await resolveDevCoords(pool(), { pmid: pm.dv.pmid, dlat: pm.dv.lat, dlon: pm.dv.lon, address: fa })
if (dev) { payload.latitude = dev.lat; payload.longitude = dev.lon; coordSrc = 'phone_' + dev.src }
else { const g = await geocodeRQA(pm.dv.address1, pm.dv.zip, pm.dv.city); if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'phone_rqa' } }
}
}
if (!coordSrc) { // DERNIER RECOURS : centre de la ville du sujet (terrain sans compte/adresse, ex. messages vocaux) → pin secteur approximatif
const tc = await townCenterFromSubject(t.subject)
if (tc) { payload.latitude = tc.lat; payload.longitude = tc.lon; coordSrc = 'ville_centre'; if (!payload.address) payload.address = tc.ville + ' (secteur — adresse à confirmer)' }
}
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, parent: Number(t.parent) || 0 }, 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
}
// Assure que l'Issue ERPNext (matchée par legacy_ticket_id) porte le `customer` → le ticket legacy apparaît dans la fiche
// client (useClientData.loadTickets filtre les Issue par `customer`). Met à jour l'Issue si son customer est vide/différent ;
// si l'Issue manque (ticket plus récent que la dernière passe `migrate_tickets.py`), la CRÉE (minimale). Idempotent
// (re-run = 0 écriture sur les déjà-liées ; migrate_tickets.py skippe le legacy_ticket_id déjà présent → pas de doublon).
// Best-effort : une erreur ici n'empêche PAS le Dispatch Job (on log et on continue — cf. createServiceLocation).
async function ensureIssueCustomer (t, custName, { dryRun = false } = {}) {
if (!LINK_ISSUES || !custName || !t || !t.id || dryRun) return null
const legacyId = Number(t.id)
if (!Number.isFinite(legacyId)) return null
try {
const rows = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'customer'], limit: 1 })
const iss = rows && rows[0]
if (iss) {
if (iss.customer === custName) return { action: 'ok', issue: iss.name }
const r = await erp.update('Issue', iss.name, { customer: custName }) // (re)lie — n'écrit que si vide/différent
if (r && r.ok) { _createStats.issues_linked++; return { action: 'linked', issue: iss.name } }
log('ensureIssueCustomer: update échec Issue ' + iss.name + ' (ticket#' + legacyId + ') — ' + ((r && r.error) || 'update'))
return null
}
// Issue absente → create minimale. issue_type/priority OMIS volontairement (liens vers doctypes seedés → risque
// LinkValidationError) : la fiche n'en a pas besoin pour lister le ticket. Le `subject`/`status`/`opening_date`
// suffisent à un rendu correct (loadTickets trie par is_important desc, opening_date desc).
const tkStatus = String(t.tk_status || 'open').toLowerCase()
const doc = {
subject: (decodeEntities(t.subject || '').trim() || ('Ticket #' + legacyId)).slice(0, 255),
status: tkStatus === 'closed' ? 'Closed' : tkStatus === 'pending' ? 'On Hold' : 'Open',
customer: custName,
company: ISSUE_COMPANY,
legacy_ticket_id: legacyId,
}
const od = tzDate(t.date_create); if (od) doc.opening_date = od
const r = await erp.create('Issue', doc)
if (r && r.ok && r.name) { _createStats.issues_created++; return { action: 'created', issue: r.name } }
log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create'))
} catch (e) { log('ensureIssueCustomer: exception ticket#' + legacyId + ' — ' + (e && e.message)) }
return 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
}
// INGESTION des tickets ASSIGNÉS À UN TECH (≠ pool 3301) → Dispatch Job assignés + datés, ÉDITABLES/réordonnables dans Ops.
// Idempotent par legacy_ticket_id (réutilise buildJob → coords/client/adresse/sujet+#/scheduled_date/legacy_dept-couleur).
// Terrain seulement + fenêtre due_date [loDays, hiDays] (défaut -7j→+30j) pour borner le volume. dryRun = APERÇU (0 écriture).
async function ingestAssignedImpl ({ dryRun = false, loDays = -7, hiDays = 30, includeClosed = false, allDates = false } = {}) {
resetCaches()
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible sur le hub' }
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
const staffToTech = {}; const ids = []
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); if (s >= 2 && s !== TARGO_TECH_STAFF_ID) { staffToTech[s] = t.technician_id || t.name; ids.push(s) } }
if (!ids.length) return { ok: true, dryRun, created: 0, note: 'aucun tech mappé' }
const now = Math.floor(Date.now() / 1000), D = 86400
const lo = now + loDays * D, hi = now + hiDays * D
// Compte/adresse EFFECTIFS via le ticket PARENT (cf. fetchTargoTickets) : un enfant sans account_id hérite du parent
// → se rattache au bon client + apparaît dans la fiche. Même jointure `pt` + COALESCE(propre, parent).
const [rows] = await p.query(
`SELECT t.id, t.assign_to, t.status AS tk_status, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id,
COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) AS account_id,
COALESCE(NULLIF(t.delivery_id, 0), NULLIF(pt.delivery_id, 0)) AS delivery_id,
t.parent, t.waiting_for, t.participant, t.followed_by, t.important,
t.date_create, t.last_update,
a.first_name, a.last_name, a.company, a.email, a.cell, a.tel_home, a.address1, a.address2, a.city, a.state, a.zip,
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.placemarks_id AS dv_pmid, 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 pt ON pt.id = t.parent
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
LEFT JOIN account a ON a.id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0))
LEFT JOIN delivery dv ON dv.id = COALESCE(NULLIF(t.delivery_id, 0), NULLIF(pt.delivery_id, 0),
(SELECT d2.id FROM delivery d2 WHERE COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) > 0 AND d2.account_id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) 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 COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) > 0 AND d3.account_id = COALESCE(NULLIF(t.account_id, 0), NULLIF(pt.account_id, 0)) ORDER BY d3.id DESC LIMIT 1))
WHERE ${includeClosed ? '' : "t.status = 'open' AND "}t.assign_to IN (?)${allDates ? '' : ' AND t.due_date BETWEEN ? AND ?'}
ORDER BY t.due_date ASC`, allDates ? [ids] : [ids, lo, hi])
// Terrain : élargi pour rattraper les vraies interventions (coupure/bris/modem/fibre/ONU/décodeur/ramassage/…),
// pas seulement install/réparation. Testé sur sujet+département. NEG_RE exclut congés/absences/réunions/formations.
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall|coupure|bris|signal|modem|\bonu\b|\bont\b|fibre|ramassage|connect|d[eé]viation|antenne|prise|internet|d[eé]m[eé]nagement|changement|activation|\bstb\b|d[eé]codeur/i
const NEG_RE = /cong[eé]|absence|vacances|\bf[eé]ri[eé]|r[eé]union|formation/i
let created = 0, updated = 0, skipped = 0, errors = 0
const details = [], errSamples = []
for (const t of (rows || [])) {
try {
const subj = String(t.subject || '')
if (NEG_RE.test(subj)) continue // jamais de congés/absences/réunions/formations (pas des interventions terrain)
// Terrain : « est. time » dans le sujet OU mot-clé terrain dans le sujet/département. S'applique aux DEUX modes
// (ingestion normale + récup. historique) → rattrape « Coupure d'internet St-Stanislas » sans ingérer l'admin.
if (!(/est\.\s?time/i.test(subj) || FIELD_RE.test(subj + ' ¦ ' + (t.dept || '')))) continue
const tech = staffToTech[t.assign_to]; if (!tech) continue
// Court-circuit : déjà importé + assigné + géolocalisé → RIEN à faire. Évite le géocodage COÛTEUX de buildJob à CHAQUE
// passage sur ~1000 tickets (le gros du temps). buildJob n'est appelé que pour un ticket neuf ou incomplet.
const hasCo = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 1e-4
const ex = await findExisting(String(t.id))
if (ex && ex.assigned_tech && hasCo(ex.latitude) && hasCo(ex.longitude)) { skipped++; if (dryRun) details.push({ legacy_id: String(t.id), action: 'exists-complete' }); continue }
const b = await buildJob(t, { dryRun })
const isClosed = String(t.tk_status) === 'closed'
b.payload.assigned_tech = tech; b.payload.status = isClosed ? 'Completed' : 'assigned' // ticket F fermé → job déjà fait
if (ex) {
const patch = {}
if (!ex.assigned_tech) { patch.assigned_tech = tech; patch.status = 'assigned' } // ne CLOBBE jamais un tech déjà posé par le répartiteur
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
if (!ex.scheduled_date && b.payload.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
const hasC = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 1e-4
if (b.payload.latitude != null && !(hasC(ex.latitude) && hasC(ex.longitude))) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude }
if (dryRun) { skipped++; details.push({ legacy_id: b.legacy_id, action: 'exists', job: ex.name, tech, would_patch: Object.keys(patch), coord_src: b.matched.coord_src }) }
else if (Object.keys(patch).length) { const r = await erp.update('Dispatch Job', ex.name, patch); if (r && r.ok) updated++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'update' }) } }
else skipped++
} else if (dryRun) {
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', tech, subject: b.payload.subject, dept: t.dept, scheduled_date: b.payload.scheduled_date || null, skill: deptSkill(t.dept || subj), customer: b.matched.customer_name, customer_matched: b.matched.customer, coords: b.matched.coords, coord_src: b.matched.coord_src })
} else {
const r = await erp.create('Dispatch Job', b.payload)
if (r && r.ok) created++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) }
}
// Relie l'Issue sous-jacente au client (les DJ « complets » court-circuités plus haut sont couverts par backfillIssueCustomers).
if (b.payload.customer) await ensureIssueCustomer(t, b.payload.customer, { dryRun })
} catch (e) { errors++; errSamples.push({ legacy_id: String(t.id), error: String((e && e.message) || e) }) }
}
return { ok: true, dryRun, window_days: [loDays, hiDays], candidates: (rows || []).length, created, updated, skipped, errors, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, error_samples: errSamples.slice(0, 6), sample: details.slice(0, 25) }
}
function ingestAssigned (opts = {}) { const run = _syncLock.then(() => ingestAssignedImpl(opts), () => ingestAssignedImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } // même verrou séquentiel que sync (frappe_pg)
// 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, { dryRun })
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, parent: b.matched.parent, 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 }) }
}
// Relie l'Issue sous-jacente au client (indépendant du DJ : couvre aussi le cas « DJ déjà là mais Issue.customer vide »).
if (b.payload.customer) await ensureIssueCustomer(t, b.payload.customer, { dryRun })
} 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, created_customers: _createStats.customers, created_service_locations: _createStats.service_locations, issues_linked: _createStats.issues_linked, issues_created: _createStats.issues_created, 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 t.account_id > 0 AND 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 t.account_id > 0 AND 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++; audit.record({ job: j.name, ticket: String(j.legacy_ticket_id), event: 'assigned', from: String(tkrow.assign_to || ''), to: j.assigned_tech, actor: actorEmail || 'ops', source: 'ops-push' }); 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' }
// ticket + CONTACT client (jointure compte F) — lecture seule. Sert l'affichage Ops ET le lien conversation (P1, getOrCreateConvForLegacyTicket).
const [trows] = await p.query(
`SELECT t.subject, t.status, t.account_id,
a.first_name, a.last_name, a.company, a.email, a.cell, a.tel_home
FROM ticket t LEFT JOIN account a ON a.id = t.account_id
WHERE t.id = ? LIMIT 1`, [id])
if (!trows || !trows[0]) return { ok: false, error: 'ticket introuvable' } // ticket inexistant → NE PAS créer de conversation fantôme (LEFT JOIN renvoyait une ligne vide)
const t = trows[0]
const cname = [t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || ''
const contact = {
name: cname || null,
email: (String(t.email || '').trim()) || null,
phone: (String(t.cell || t.tel_home || '').trim()) || null,
account_id: t.account_id || null,
}
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'),
fromClient: !r.staff_id, // staff_id 0/null = message du client (utile pour le fil fusionné P3/P4)
text: stripHtml(r.msg, 6000),
})).filter(m => m.text)
return { ok: true, ticket: id, subject: decodeEntities(t.subject || ''), status: t.status || '', contact, count: messages.length, messages }
}
// Fil + CONTACT d'un Dispatch Job, source unifiée : osTicket si legacy_ticket_id, SINON fiche client ERPNext (jobs natifs ex. FR-…).
// Lecture seule — ne crée AUCUNE conversation (l'aperçu de la feuille ne doit pas générer de convs). Pour les jobs ERPNext, le fil
// = messages de la conversation déjà liée (si elle existe), sinon vide. Le contact ERPNext vient de la fiche (email_id / mobile_no).
async function jobThread (jobName) {
const jn = String(jobName || '').trim(); if (!jn) return { ok: false, error: 'job requis' }
let job
try { const jl = await erp.list('Dispatch Job', { filters: [['name', '=', jn]], fields: ['name', 'customer', 'legacy_ticket_id', 'subject'], limit: 1 }); job = jl && jl[0] } catch (e) { return { ok: false, error: e.message } }
if (!job) return { ok: false, error: 'job introuvable' }
if (job.legacy_ticket_id) return await ticketThread(job.legacy_ticket_id) // osTicket : contact + fil ticket_msg
let cust = null
try { if (job.customer) { const cl = await erp.list('Customer', { filters: [['name', '=', job.customer]], fields: ['name', 'customer_name', 'email_id', 'mobile_no'], limit: 1 }); cust = cl && cl[0] } } catch (e) {}
const contact = { name: (cust && cust.customer_name) || null, email: (cust && cust.email_id) || null, phone: (cust && cust.mobile_no) || null, account_id: null, customer: job.customer || null }
let messages = []
try {
const conv = require('./conversation').findConvByJob(jn)
if (conv && Array.isArray(conv.messages)) messages = conv.messages.map(m => ({
at: m.ts || m.at || null,
author: m.from === 'agent' ? 'Nous' : (m.from === 'system' ? 'Système' : (contact.name || 'Client')),
fromClient: m.from !== 'agent' && m.from !== 'system',
text: String(m.text || (m.html ? String(m.html).replace(/<[^>]+>/g, ' ') : '')).replace(/\s+/g, ' ').trim(),
})).filter(m => m.text)
} catch (e) {}
return { ok: true, ticket: null, subject: decodeEntities(job.subject || ''), status: '', contact, count: messages.length, messages, source: 'erpnext' }
}
// ── 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
// Map staff legacy <id> → Dispatch Technician TECH-<id> (même logique que l'ingestion des tickets assignés).
async function loadStaffToTech () {
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
const map = {}
for (const t of (techs || [])) {
const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue
const s = Number(m[1]); if (s >= 2 && s !== TARGO_TECH_STAFF_ID) map[s] = t.technician_id || t.name
}
return map
}
// MIROIR d'assignation : quand F assigne/réassigne un ticket à un vrai tech, on REFLÈTE le tech sur le Dispatch Job
// (assigned_tech + status assigned + scheduled_date d'après le due_date) au lieu de l'annuler → la TRACE est conservée
// (le job reste visible SOUS LE BON TECH dans le board et l'historique). Renvoie false si le tech n'est pas mappé.
async function mirrorAssign (job, staffId, dueTs, staffToTech) {
const tech = staffToTech[staffId]; if (!tech) return false
const patch = { assigned_tech: tech, status: 'assigned' }
const ts = Number(dueTs) || 0
if (ts > 0) { try { patch.scheduled_date = new Date(ts * 1000).toISOString().slice(0, 10) } catch (e) {} }
try { await erp.update('Dispatch Job', job.name, patch); return true } catch (e) { return false }
}
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, due_date FROM ticket WHERE id IN (?)', [ids])
const staffToTech = await loadStaffToTech() // pour REFLÉTER l'assignation F sur le job (conserver la trace)
const seeded = _watchSnap.size > 0 // 1er passage (snapshot vide) = amorçage silencieux des DELTAS
const changes = []; let closedNow = 0; let pulled = 0; let mirrored = 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) {
// F a assigné ce job (du pool) à un vrai tech. On REFLÈTE le tech sur le job (trace conservée, visible sous le bon
// tech) plutôt que de l'annuler. Repli sur Cancelled seulement si le tech F n'a pas de fiche Dispatch Technician.
if (await mirrorAssign(j, at, cur.due_date, staffToTech)) {
mirrored++; audit.record({ job: j.name, ticket: id, event: 'mirror-assigned', to: staffToTech[at], actor: 'F', source: 'F-watch' })
changes.push({ ticket: id, job: j.name, kind: 'mirror-assigned', to_staff: cur.assign_to, tech: staffToTech[at], subject: j.subject || '' })
} else {
try { await erp.update('Dispatch Job', j.name, { status: 'Cancelled' }); pulled++; audit.record({ job: j.name, ticket: id, event: 'cancelled', actor: 'F', source: 'F-watch' }); changes.push({ ticket: id, job: j.name, kind: 'taken', to_staff: cur.assign_to, subject: j.subject || '' }) } catch (e) {}
}
continue // reflété (ou 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)
audit.record({ job: j.name, ticket: id, event: 'completed', to: j.assigned_tech || '', actor: 'F', source: 'F-watch' })
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) {
const nat = parseInt(cur.assign_to, 10) || 0
// Réassignation F (tech → autre tech) → refléter le nouveau tech sur le job pour que la trace suive la réalité F.
if (nat > 0 && nat !== TARGO_TECH_STAFF_ID && await mirrorAssign(j, nat, cur.due_date, staffToTech)) mirrored++
audit.record({ job: j.name, ticket: id, event: 'reassigned', from: j.assigned_tech || '', to: staffToTech[nat] || (nat === TARGO_TECH_STAFF_ID ? '(pool)' : ''), actor: 'F', source: 'F-watch' })
changes.push({ ticket: id, job: j.name, kind: 'reassigned', to_staff: cur.assign_to, pool: cur.assign_to === String(TARGO_TECH_STAFF_ID), tech: staffToTech[nat] || 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, mirrored, changes: changes.length }
return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, 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
// Garde anti-chevauchement : si un import dépasse l'intervalle (beaucoup de tickets), NE PAS relancer par-dessus
// (sinon doublons Dispatch Job + contention ERPNext). On saute le tick tant que le précédent tourne.
let _syncInFlight = false
// Import AUTO à chaque tick : (1) le pool 3301 (sync) PUIS (2) les tickets assignés à un vrai tech (ingestAssigned,
// allDates → tous les tickets terrain OUVERTS assignés, pas seulement la fenêtre due_date ±jours). Chaînés (même _syncLock,
// pas de chevauchement). Désactivable via LEGACY_DISPATCH_ASSIGNED_AUTO=off (garde le pool seul).
const assignedAuto = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_ASSIGNED_AUTO || ''))
// ingestAssigned re-scanne ~1000 tickets → on ne le lance qu'1 tick sur N (défaut 4 ≈ horaire à 15 min), pas à chaque tick.
// (Le court-circuit « déjà importé » rend les passages en régime permanent rapides, mais on borne quand même la cadence.)
const assignedEvery = Math.max(1, Number(process.env.LEGACY_DISPATCH_ASSIGNED_EVERY) || 4)
let _tickN = 0
const tick = () => {
if (_syncInFlight) { log('legacy-dispatch-sync: passage précédent encore en cours → tick sauté'); return }
_syncInFlight = true
const runAssigned = assignedAuto && (_tickN % assignedEvery === 0); _tickN++
sync({ dryRun: false })
.then(() => runAssigned ? ingestAssigned({ dryRun: false, allDates: true }) : null)
.catch(e => log('legacy-dispatch-sync tick error:', e.message))
.finally(() => { _syncInFlight = false })
}
// 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
let _watchInFlight = false
const wtick = () => { if (_watchInFlight) return; _watchInFlight = true; watchLegacy().catch(e => log('legacy-watch error:', e.message)).finally(() => { _watchInFlight = false }) }
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 } }
// Jobs Legacy (osTicket) OUVERTS assignés à UN tech précis (staff_id legacy), filtrés « terrain »
// (dépts dispatch : install/réparation/télé/téléphonie/monteur/fusion/désinstall OU subject « est. time »).
// LECTURE SEULE : 1 requête par appel (au clic dans Ops), zéro écriture. Pool plafonné à 2 conns (prod legacy protégée).
async function legacyTechTickets (staffId) {
const sid = Number(staffId); if (!sid) return { ok: false, error: 'staff_id invalide' }
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [rows] = await p.query(
`SELECT t.id, t.subject, dd.name AS dept, t.due_date, t.priority, a.first_name, a.last_name, a.company, a.city
FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id LEFT JOIN account a ON a.id = t.account_id
WHERE t.status = 'open' AND t.assign_to = ?
ORDER BY (t.due_date IS NULL OR t.due_date = 0), t.due_date ASC LIMIT 400`, [sid])
const decode = decodeEntities
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i
const all = (rows || []).map(r => {
const subject = decode(r.subject)
const est = (subject.match(/est\.\s?time\s*([0-9hm: ]+)/i) || [])[1] || ''
return {
id: String(r.id), subject, dept: r.dept || '',
customer: r.company || [r.first_name, r.last_name].filter(Boolean).join(' ') || '',
city: r.city || '',
due: (r.due_date && Number(r.due_date) > 0) ? new Date(Number(r.due_date) * 1000).toISOString().slice(0, 10) : '',
est: est.trim(),
field: /est\.\s?time/i.test(subject) || FIELD_RE.test(r.dept || ''),
reply: `https://store.targo.ca/targo/reply_ticket.php?ticket=${r.id}`,
}
})
const jobs = all.filter(t => t.field)
const ids = jobs.map(t => t.id)
let inDispatch = new Set()
if (ids.length) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', 'in', ids]], fields: ['legacy_ticket_id'], limit: 1000 }); inDispatch = new Set((djs || []).map(d => String(d.legacy_ticket_id))) } catch (e) {} }
for (const t of jobs) t.in_dispatch = inDispatch.has(t.id)
return { ok: true, staff_id: sid, total_open: all.length, hidden_non_field: all.length - jobs.length, jobs }
}
// Dépt/sujet legacy → COMPÉTENCE (réplique roster.js deptToSkill) → couleur du bloc identique aux jobs importés.
function deptSkill (txt) {
const d = String(txt || '').toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '')
if (/teleph/.test(d)) return 'telephone'
if (/tele|televis/.test(d)) return 'tv'
if (/fusion|episs/.test(d)) return 'épissure'
if (/monteur|aerien/.test(d)) return 'monteur'
if (/netadmin|net admin/.test(d)) return 'netadmin'
if (/repar|desinstall/.test(d)) return 'réparation'
if (/install|fibre/.test(d)) return 'installation'
return ''
}
// Durée estimée d'un job legacy : « est. time XhYm » dans le sujet, sinon défaut par dépt.
function estLegacyHours (subject, dept) {
const s = String(subject || '')
let m = s.match(/est\.\s?time\s*(\d+)\s*h(?:\s*(\d+)\s*m)?/i)
if (m) return Math.round(((+m[1]) + (m[2] ? (+m[2]) / 60 : 0)) * 10) / 10
m = s.match(/est\.\s?time\s*(\d+)\s*m/i)
if (m) return Math.round((+m[1]) / 60 * 10) / 10
const d = String(dept || '').toLowerCase()
if (/install/.test(d)) return 2
if (/r[eé]paration|fusion/.test(d)) return 1.5
return 1
}
// Charge LEGACY (osTicket) DATÉE par tech×jour sur la fenêtre [start, start+days[ : pour « appliquer » les durées
// estimées des jobs assignés directement dans le legacy sur les timelines, MAIS seulement pour les journées affichées.
// 1 requête bornée par due_date (petit résultat). Clé = technician_id|iso (= la clé d'occupancy côté Ops).
async function legacyWindowLoad (start, days) {
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
const n = Math.max(1, Math.min(31, Number(days) || 7))
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
const staffToTech = {}; const ids = []
for (const t of (techs || [])) { const mm = String(t.technician_id || '').match(/(\d+)$/); if (!mm) continue; const sx = Number(mm[1]); if (sx >= 2 && sx !== TARGO_TECH_STAFF_ID) { staffToTech[sx] = t.technician_id || t.name; ids.push(sx) } } // exclut le pool « Tech Targo » (3301) — backlog, pas une personne
if (!ids.length) return { ok: true, load: {} }
const isos = []; for (let i = 0; i < n; i++) { const dd = new Date(start + 'T12:00:00Z'); dd.setUTCDate(dd.getUTCDate() + i); isos.push(dd.toISOString().slice(0, 10)) }
const isoSet = new Set(isos)
const startUnix = Math.floor(Date.parse(start + 'T00:00:00Z') / 1000)
const lo = startUnix - 2 * 86400, hi = startUnix + (n + 2) * 86400
const decode = decodeEntities
// Tickets lus sur F LIVE via le pont PHP (le miroir n'est PAS rafraîchi pour `ticket` → périmé). Repli miroir si pont indispo.
let rows = []
try {
const w = await legacyWrite({ action: 'tickets_window', lo, hi, staff: ids.join(',') })
rows = (w && w.data && Array.isArray(w.data.rows)) ? w.data.rows : []
} catch (e) {
try { const [mr] = await p.query(`SELECT t.id, t.subject, t.assign_to, t.due_date, dd.name AS dept FROM ticket t LEFT JOIN ticket_dept dd ON dd.id = t.dept_id WHERE t.status = 'open' AND t.assign_to IN (?) AND t.due_date BETWEEN ? AND ?`, [ids, lo, hi]); rows = mr } catch (e2) {}
}
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/i // jobs TERRAIN (cohérent avec le 📋) — exclut facturation/vente/admin
// Exclut les tickets DÉJÀ ingérés comme vrais Dispatch Job (legacy_ticket_id) → ils s'affichent via l'occupancy ; évite le DOUBLON sur la timeline.
let ingested = new Set()
// Le Set « ingéré » = EXACTEMENT les tickets que l'occupation DESSINE (occupancyByTechDay filtre status in [open,assigned,in_progress]).
// Sinon un DJ Completed/Cancelled/closed supprime le synthétique SANS rien dessiner → ticket F encore ouvert = caché des DEUX côtés
// (cas Nathan : 6 DJ Cancelled ; + 116 DJ Completed au total). F reste autoritaire (ticket ouvert+assigné+dû) → on redessine le bloc.
try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'in_progress']]], fields: ['legacy_ticket_id'], limit: 5000 }); ingested = new Set((djs || []).map(d => String(d.legacy_ticket_id))) } catch (e) {}
const load = {}
for (const r of (rows || [])) {
const tech = staffToTech[r.assign_to]; if (!tech || !r.due_date) continue
if (ingested.has(String(r.id))) continue // déjà un vrai Dispatch Job → pas de bloc synthétique
const subj = decode(r.subject)
// TERRAIN = exige un DÉPLACEMENT (install / réparation / TV / téléphonie / fusion) → bloc horaire.
// « NP - » = note/suivi rapide (≤2 min, souvent au support) → JAMAIS terrain même si le dept matche → fine ligne verticale.
// Le reste (Facturation, Vente, ToDo, Support…) → non-terrain → fine ligne verticale aussi. On liste tout, mais seul le terrain occupe la charge.
const npQuick = /\bNP\b\s*-/i.test(subj)
const isField = !npQuick && (/est\.\s?time/i.test(subj) || FIELD_RE.test(r.dept || ''))
const iso = new Date(Number(r.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
if (!isoSet.has(iso)) continue
const est = estLegacyHours(r.subject, r.dept)
const k = tech + '|' + iso
const e = load[k] || (load[k] = { h: 0, n: 0, officeN: 0, jobs: [] })
// Seul le TERRAIN compte dans la charge horaire (occupation/surcharge). L'admin/facturation est listée (jobs) mais ne gonfle pas l'occupation.
if (isField) { e.h += est; e.n++ } else { e.officeN++ }
e.jobs.push({ id: String(r.id), subject: subj, est_h: est, dept: r.dept || '', skill: deptSkill(r.dept || subj), field: isField })
}
for (const k in load) load[k].h = Math.round(load[k].h * 10) / 10
// POSITIONNEMENT CARTE : enrichit chaque job TERRAIN avec coords + adresse de service + nom client, via la MÊME
// logique que l'ingest : coords delivery F (point de service réel) en priorité, sinon géocodage RQA (local) de
// l'adresse de service puis de facturation. 1 requête groupée + géocodage par lots (borne la charge RQA).
try {
const fieldJobs = []
for (const lk in load) for (const j of (load[lk].jobs || [])) if (j.field) fieldJobs.push(j)
if (fieldJobs.length) {
const [crows] = await p.query(
`SELECT t.id AS tid, t.account_id AS acct, a.first_name, a.last_name, a.company,
a.address1 AS bill_addr, a.city AS bill_city, a.zip AS bill_zip,
dv.latitude AS lat, dv.longitude AS lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip, dv.placemarks_id AS dv_pmid
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 t.account_id > 0 AND 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 t.account_id > 0 AND d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
)
WHERE t.id IN (?)`,
[fieldJobs.map(j => Number(j.id))],
)
const cmap = {}
for (const cr of (crows || [])) cmap[String(cr.tid)] = cr
// Coords delivery fiables d'abord (HORS camping) ; le reste → MÊME algorithme que l'ingest, par LOTS (2 appels bridge
// pour TOUTE la fenêtre). CAMPING = le LOT (placemarks.nom) prime, coords delivery de camping (souvent erronées) en dernier.
const campings = await getCampings()
const recs = []
for (const j of fieldJobs) {
const cr = cmap[String(j.id)]; if (!cr) continue
j.customer = [cr.first_name, cr.last_name].filter(Boolean).join(' ') || cr.company || ''
j.address = [cr.dv_addr, cr.dv_city, cr.dv_zip].filter(Boolean).join(', ') || [cr.bill_addr, cr.bill_city, cr.bill_zip].filter(Boolean).join(', ')
const camp = campingFor(campings, [j.subject, cr.dv_city, cr.dv_addr])
const dc = coord(cr.lat, cr.lon)
if (!camp && dc) { j.lat = dc.lat; j.lon = dc.lon; continue } // hors camping : coords delivery = fiables
const a = _parseAddr(j.address)
recs.push({ j, acct: cr.acct, camp: camp || null, pmid: cr.dv_pmid || null, dlat: cr.lat, dlon: cr.lon, zip: a.zip, civic: a.civic, street: a.street, line: cr.dv_addr || cr.bill_addr, gzip: cr.dv_zip || cr.bill_zip, gcity: cr.dv_city || cr.bill_city })
}
for (const r of recs.slice(0, 250)) { // fibre par adresse (rue désambiguïsée) → pmid/coords pour les lots sans delivery.placemarks_id (DB locale rapide)
if (!r.pmid && r.zip && r.civic) { const fm = await fibreMatch(p, r.zip, r.civic, r.street); if (fm) { r.pmid = fm.pmid || null; r.flat = fm.flat; r.flon = fm.flon } }
r.variants = lotVariants(String(r.j.address || '').split(',')[0])
}
const fresh = await placemarksLookup([...new Set(recs.map(r => r.pmid).filter(Boolean))]) // 1 SEUL appel bridge / fenêtre
const allNoms = new Set(); recs.forEach(r => (r.variants || []).forEach(n => allNoms.add(n)))
const byNom = allNoms.size ? await placemarksByNom([...allNoms]) : {} // 1 SEUL appel bridge / fenêtre
const toGeo = []
for (const r of recs) {
const pm = r.pmid != null ? fresh[String(r.pmid)] : null
const nm = (r.variants || []).map(x => byNom[x]).find(x => x && _inQC(x.lat, x.lon)) || null
const dc2 = coord(r.dlat, r.dlon)
if (r.camp) { // CAMPING : lot précis (nom) > placemarks_id frais > centroïde camping > fibre > delivery (en dernier)
if (nm) { r.j.lat = nm.lat; r.j.lon = nm.lon }
else if (pm && _inQC(pm.lat, pm.lon)) { r.j.lat = pm.lat; r.j.lon = pm.lon }
else if (_inQC(r.camp.latitude, r.camp.longitude)) { r.j.lat = +r.camp.latitude; r.j.lon = +r.camp.longitude }
else if (_inQC(r.flat, r.flon)) { r.j.lat = +r.flat; r.j.lon = +r.flon }
else if (dc2) { r.j.lat = dc2.lat; r.j.lon = dc2.lon }
else if (r.line) toGeo.push({ j: r.j, acct: r.acct, line: r.line, zip: r.gzip, city: r.gcity })
} else { // hors camping : placemarks_id frais > fibre > nom de lot > RQA (les coords delivery fiables sont déjà prises)
if (pm && _inQC(pm.lat, pm.lon)) { r.j.lat = pm.lat; r.j.lon = pm.lon }
else if (_inQC(r.flat, r.flon)) { r.j.lat = +r.flat; r.j.lon = +r.flon }
else if (nm) { r.j.lat = nm.lat; r.j.lon = nm.lon }
else if (r.line) toGeo.push({ j: r.j, acct: r.acct, line: r.line, zip: r.gzip, city: r.gcity })
}
}
const cap = toGeo.slice(0, 150) // borne la charge RQA sur une grande fenêtre
if (toGeo.length > cap.length) log('legacyWindowLoad: géocodage limité à', cap.length, 'sur', toGeo.length, 'jobs sans coords delivery/infra')
for (let i = 0; i < cap.length; i += 10) { // par lots de 10 (perf word_similarity RQA)
await Promise.all(cap.slice(i, i + 10).map(async (x) => { try { const g = await geocodeRQA(x.line, x.zip, x.city); if (g) { x.j.lat = g.lat; x.j.lon = g.lon; persistGeocodeToSL(x.acct, g.lat, g.lon, x.city) } } catch (e) {} })) // CONTINU : la coord géocodée alimente le Service Location ERPNext (magasin canonique)
}
// Jobs terrain ENCORE sans coords (sans compte/adresse, ex. messages vocaux) : 1) client par TÉLÉPHONE du sujet → adresse précise ;
// 2) sinon centre de la VILLE du sujet → pin secteur approximatif.
for (const r of recs) {
if (r.j.lat && Math.abs(+r.j.lat) > 1) continue
try { const pm = await accountByPhone(p, r.j.subject); if (pm) { const fa = [pm.dv.address1, pm.dv.city, pm.dv.zip].filter(Boolean).join(', '); let c = await resolveDevCoords(p, { pmid: pm.dv.pmid, dlat: pm.dv.lat, dlon: pm.dv.lon, address: fa }); if (!c) { const g = await geocodeRQA(pm.dv.address1, pm.dv.zip, pm.dv.city); if (g) c = { lat: g.lat, lon: g.lon } } if (c) { r.j.lat = c.lat; r.j.lon = c.lon; r.j.phone_match = pm.customer_name; if (!r.j.customer) r.j.customer = pm.customer_name; if (!r.j.address) r.j.address = fa } } } catch (e) {}
if (r.j.lat && Math.abs(+r.j.lat) > 1) continue
try { const tc = await townCenterFromSubject(r.j.subject); if (tc) { r.j.lat = tc.lat; r.j.lon = tc.lon; r.j.approx_town = tc.ville } } catch (e) {}
}
}
} catch (e) { log('legacyWindowLoad coords:', e.message) } // best-effort : sans coords le job reste listé mais non géolocalisé
return { ok: true, load }
}
// Lookup coords FRAÎCHES de la carte d'infra (placemarks @10.5.14.89) via le bridge PHP sur F (le hub n'a pas le grant direct).
// ids = placemarks_id ; renvoie { "<id>": {lat, lon} }. Batch par 500. Token = même X-Ops-Token que ops_reassign.
async function placemarksLookup (ids) {
const uniq = [...new Set((ids || []).map(n => parseInt(n, 10)).filter(Boolean))]
if (!uniq.length) return {}
const out = {}
for (let i = 0; i < uniq.length; i += 500) {
try {
const r = await httpRequest('https://facturation.targo.ca/ops_placemarks.php', '', { method: 'POST', body: JSON.stringify({ ids: uniq.slice(i, i + 500) }), headers: { 'Content-Type': 'application/json', 'X-Ops-Token': opsLegacyToken() }, timeout: 20000 })
const c = (r && r.data && r.data.coords) || {}
for (const k in c) out[k] = c[k]
} catch (e) { log('placemarksLookup: ' + e.message) }
}
return out
}
// Recherche placemarks par NOM de lot (campings) via le bridge → { "<nom>": {lat,lon,id} } (match UNIQUE seulement = sûr).
async function placemarksByNom (noms) {
const uniq = [...new Set((noms || []).map(s => String(s || '').trim().replace(/\s+/g, ' ')).filter(Boolean))]
if (!uniq.length) return {}
const out = {}
for (let i = 0; i < uniq.length; i += 400) {
try {
const r = await httpRequest('https://facturation.targo.ca/ops_placemarks.php', '', { method: 'POST', body: JSON.stringify({ noms: uniq.slice(i, i + 400) }), headers: { 'Content-Type': 'application/json', 'X-Ops-Token': opsLegacyToken() }, timeout: 30000 })
const c = (r && r.data && r.data.byNom) || {}
for (const k in c) out[k] = c[k]
} catch (e) { log('placemarksByNom: ' + e.message) }
}
return out
}
// Variantes d'un « lot » de camping pour le match par nom — ROBUSTE AUX VARIANTES D'IMPORT (le placemark.nom est court,
// ex. « 9 Jaune », alors que l'adresse client a « 9 rue Jaune », un suffixe « Camping … », des accents (collation gère),
// un code postal erroné). On retire les types de voie + le suffixe camp pour s'aligner. Match toujours UNIQUE côté bridge.
function lotVariants (addr) {
const base = String(addr || '').trim().replace(/\s+/g, ' ')
if (!base) return []
const set = new Set([base])
const noCamp = base.replace(/\s+(camping|camp|lac des pins|domaine|terrain|parc|secteur)\b.*$/i, '').trim()
if (noCamp) set.add(noCamp)
for (const v of [base, noCamp]) {
if (!v) continue
const s = v.replace(/\b(rue|av|ave|avenue|ch|chemin|rang|mont[ée]e?|boul(?:evard)?|blvd|place|impasse|terrasse|croissant|all[ée]e)\b/gi, ' ').replace(/\s+/g, ' ').trim()
if (s) set.add(s)
}
return [...set].filter(s => s && s.length >= 2)
}
// Géoloc des adresses de service (delivery) SANS coords, via la carte d'infra F : delivery → service → fibre → placemarks_id
// → coords FRAÎCHES (bridge) sinon fibre.lat/lon (repli). Borne Québec (filtre les placemarks bidons). dryRun=aperçu.
const _inQC = (lat, lon) => { const a = +lat, o = +lon; return isFinite(a) && isFinite(o) && a >= 44 && a <= 63 && o >= -80 && o <= -57 }
async function geolocateFromInfra ({ dryRun = true, limit = 1000, jobs = true } = {}) {
const p = pool(); if (!p) return { ok: false, error: 'legacy DB indispo' }
let sls = []
try { sls = await erp.list('Service Location', { fields: ['name', 'latitude', 'longitude', 'legacy_delivery_id'], limit: 8000 }) } catch (e) { return { ok: false, error: 'Service Location: ' + e.message } }
const missing = sls.filter(s => s.legacy_delivery_id && (!s.latitude || Math.abs(+s.latitude) < 1)).slice(0, limit)
if (!missing.length) return { ok: true, dryRun, missing: 0, message: 'aucune adresse de service sans coords' }
const dids = [...new Set(missing.map(s => +s.legacy_delivery_id).filter(Boolean))]
// 1) delivery DIRECT : placemarks_id + lat/lon + zip/civique (pour le repli fibre des campings non-RQA)
const del = {} // did → { pmid, dlat, dlon, zip, civ, flat, flon }
for (let i = 0; i < dids.length; i += 500) {
const [rows] = await p.query('SELECT id, placemarks_id pmid, latitude dlat, longitude dlon, zip, address1 FROM delivery WHERE id IN (' + dids.slice(i, i + 500).join(',') + ')')
for (const r of rows) del[r.id] = { pmid: r.pmid || null, dlat: r.dlat, dlon: r.dlon, zip: String(r.zip || '').replace(/\s/g, ''), civ: (String(r.address1 || '').match(/(\d+)/) || [])[1] || '', addr: String(r.address1 || '').trim().replace(/\s+/g, ' ') }
}
// 2) REPLI campings/non-fibre : delivery sans placemarks_id ni coords valides → matcher fibre par zip+civique (comme fibre_adr_search.php)
for (const v of Object.values(del)) {
if (v.pmid || _inQC(v.dlat, v.dlon) || !v.zip || !v.civ) continue
const fm = await fibreMatch(p, v.zip, v.civ, _parseAddr(v.addr).street) // désambiguïsé par la rue
if (fm) { v.pmid = fm.pmid || null; v.flat = fm.flat; v.flon = fm.flon }
}
// 3) coords FRAÎCHES placemarks (bridge F) pour tous les placemarks_id collectés (delivery direct fibre-adresse)
const fresh = await placemarksLookup([...new Set(Object.values(del).map(v => v.pmid).filter(Boolean))])
// 3b) REPLI CAMPINGS robuste aux VARIANTES : delivery encore sans coords → match placemarks.nom = lot, en essayant
// plusieurs formes (sans type de voie « rue/avenue/… », sans suffixe « Camping … »). Match unique côté bridge = sûr.
const allNoms = new Set()
for (const v of Object.values(del)) {
const pm = v.pmid != null ? fresh[String(v.pmid)] : null
if (!(pm && _inQC(pm.lat, pm.lon)) && !_inQC(v.dlat, v.dlon) && !_inQC(v.flat, v.flon) && v.addr) { v.variants = lotVariants(v.addr); v.variants.forEach(n => allNoms.add(n)) }
}
const byNom = allNoms.size ? await placemarksByNom([...allNoms]) : {}
// 4) résoudre par priorité : placemarks(id) frais > coords delivery > fibre-adresse > placemarks par NOM (camping) ; borné Québec
const updates = []; let viaPm = 0, viaDeliv = 0, viaFibre = 0, viaNom = 0, none = 0
for (const s of missing) {
const v = del[+s.legacy_delivery_id]; if (!v) { none++; continue }
const pm = v.pmid != null ? fresh[String(v.pmid)] : null
const nm = (v.variants || []).map(x => byNom[x]).find(x => x && _inQC(x.lat, x.lon)) || null // 1re variante de lot qui matche (déjà bornée QC)
let lat, lon, src
if (pm && _inQC(pm.lat, pm.lon)) { lat = pm.lat; lon = pm.lon; src = 'placemarks'; viaPm++ }
else if (_inQC(v.dlat, v.dlon)) { lat = +v.dlat; lon = +v.dlon; src = 'delivery'; viaDeliv++ }
else if (_inQC(v.flat, v.flon)) { lat = +v.flat; lon = +v.flon; src = 'fibre_addr'; viaFibre++ }
else if (nm) { lat = nm.lat; lon = nm.lon; src = 'placemarks_nom'; viaNom++ }
else { none++; continue }
updates.push({ name: s.name, did: +s.legacy_delivery_id, lat: +(+lat).toFixed(6), lon: +(+lon).toFixed(6), src })
}
let writtenSL = 0, writtenJobs = 0, blockedNoCustomer = 0, failedSL = 0
if (!dryRun) {
// ⚠️ erp.update NE LÈVE PAS : il renvoie {ok:false,error} → toujours vérifier .ok. Une SL sans `customer` → MandatoryError au PUT.
for (const u of updates) {
const r = await erp.update('Service Location', u.name, { latitude: u.lat, longitude: u.lon }).catch(e => ({ ok: false, error: e.message }))
if (r && r.ok) writtenSL++
else if (/customer/i.test(String((r && r.error) || ''))) blockedNoCustomer++
else { failedSL++; log('geoloc SL ' + u.name + ': ' + ((r && r.error) || 'échec')) }
}
if (jobs && updates.length) {
const coordByLoc = {}; for (const u of updates) coordByLoc[u.name] = u
const locNames = updates.map(u => u.name)
for (let i = 0; i < locNames.length; i += 80) {
let dj = []
try { dj = await erp.list('Dispatch Job', { filters: [['service_location', 'in', locNames.slice(i, i + 80)], ['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]], fields: ['name', 'service_location', 'latitude'], limit: 500 }) } catch (e) { dj = [] }
for (const j of dj) { const u = coordByLoc[j.service_location]; if (u && (!j.latitude || Math.abs(+j.latitude) < 1)) { const rj = await erp.update('Dispatch Job', j.name, { latitude: u.lat, longitude: u.lon }).catch(() => ({ ok: false })); if (rj && rj.ok) writtenJobs++ } }
}
}
}
return { ok: true, dryRun, missing: missing.length, resolvable: updates.length, via_placemarks: viaPm, via_delivery: viaDeliv, via_fibre_addr: viaFibre, via_nom: viaNom, unresolved: none, written_service_locations: writtenSL, blocked_no_customer: blockedNoCustomer, failed: failedSL, written_jobs: writtenJobs, sample: updates.slice(0, 8) }
}
// ── Géoloc par ADRESSE (réutilisable, tolérant aux variantes + COMPARAISON DE RUE pour désambiguïser) ──
function _deburr (s) { return String(s == null ? '' : s).normalize('NFD').replace(/[̀-ͯ]/g, '') }
function _normStreet (s) { return _deburr(s).toLowerCase().replace(/\b(rue|av|ave|avenue|ch|chemin|rang|mont[ée]e?|boul(?:evard)?|blvd|place|impasse|terrasse|croissant|all[ée]e|du|de|des|d|la|le|les|st|ste|saint|sainte)\b/g, ' ').replace(/[^a-z0-9]+/g, ' ').trim() }
// La rue de la fibre CONFIRME-t-elle la rue de l'adresse ? (tolère le préfixe d'unité « -4 » laissé par _parseAddr, ex. « -4 Rue Elsie »)
function _streetConfirmed (rueFibre, streetAddr) { const a = _normStreet(rueFibre), b = _normStreet(streetAddr); if (!a || !b) return false; return a === b || (a.length >= 3 && b.length >= 3 && (a.includes(b) || b.includes(a))) }
function _parseAddr (address) {
const a = String(address || '').trim()
const zm = a.match(/([A-Za-z]\d[A-Za-z])\s?(\d[A-Za-z]\d)/); const zip = zm ? (zm[1] + zm[2]) : ''
const first = a.split(',')[0].trim()
// Civique AVEC le suffixe d'unité « -4 » (format F « bâtiment-unité », ex. « 102-4 ») : la fibre indexe le terrain « 102-4 »,
// donc « terrain LIKE '102-4%' » cible le BON immeuble ; « 102 » seul ratissait toutes les rues avec un civique 102.
const cm = first.match(/^\s*(\d+(?:-\d+)?)\s*[A-Za-z]?/); const civic = cm ? cm[1] : ''
const street = first.replace(/^\s*\d+(?:-\d+)?\s*[A-Za-z]?\s*/, '').trim()
return { zip, civic, street }
}
// Match fibre par zip+civique, DÉSAMBIGUÏSÉ par la rue (compare quand plusieurs civiques identiques). → ligne fibre ou null.
async function fibreMatch (p, zip, civic, street) {
const z = String(zip || '').replace(/\s/g, '').toUpperCase(); const cv = String(civic || '').trim()
if (!z || !cv) return null
let rows = []
try { [rows] = await p.query("SELECT placemarks_id pmid, latitude flat, longitude flon, rue FROM fibre WHERE REPLACE(UPPER(zip),' ','')=? AND terrain LIKE ? LIMIT 12", [z, cv + '%']) } catch (e) { return null }
if (!rows.length) return null
if (rows.length === 1) return rows[0]
const ns = _normStreet(street)
if (ns) { const m = rows.find(r => { const rr = _normStreet(r.rue); return rr && (rr === ns || (rr.length >= 4 && (rr.includes(ns) || ns.includes(rr)))) }); if (m) return m }
return null // plusieurs civiques + aucune rue concordante → on NE devine pas (évite le mauvais « 95 Rue Principale » vs « Rang du Cinq »)
}
function _haversineM (la1, lo1, la2, lo2) { const R = 6371000, r = Math.PI / 180; const dLa = (la2 - la1) * r, dLo = (lo2 - lo1) * r; const a = Math.sin(dLa / 2) ** 2 + Math.cos(la1 * r) * Math.cos(la2 * r) * Math.sin(dLo / 2) ** 2; return 2 * R * Math.asin(Math.sqrt(a)) }
// Géoloc des DISPATCH JOBS actifs : remplit ceux SANS coords ET (refreshCamp) RE-géolocalise les jobs de CAMPING déjà
// coordonnés pour upgrader le centroïde générique → le LOT précis (placemarks.nom). Campsite-aware (cf. resolveDevCoords).
// Un job DÉJÀ coordonné n'est réécrit que si le déplacement > 100 m (évite le thrash). Écrit DJ.lat/lon (aucun customer requis). dryRun => 0 écriture.
async function geolocateJobs ({ dryRun = true, limit = 3000, refreshCamp = false, refreshFibre = false } = {}) {
const p = pool(); if (!p) return { ok: false, error: 'legacy DB indispo' }
let jobs = []
try { jobs = await erp.list('Dispatch Job', { filters: [['status', 'in', ['open', 'assigned', 'in_progress', 'On Hold']]], fields: ['name', 'address', 'latitude', 'longitude'], limit: 8000 }) } catch (e) { return { ok: false, error: 'Dispatch Job: ' + e.message } }
const campings = await getCampings()
const hasC = (v) => v != null && v !== '' && Math.abs(+v) > 1e-4
// refreshFibre : RE-vérifie AUSSI les jobs DÉJÀ coordonnés (pour corriger les coords erronées, ex. rue neuve placée à Valleyfield) — n'écrase que si la fibre CONFIRME la rue.
const targets = jobs.filter(j => { const addr = String(j.address || '').trim(); if (!addr) return false; if (!hasC(j.latitude)) return true; return refreshFibre || (refreshCamp && !!campingFor(campings, [addr])) }).slice(0, limit)
if (!targets.length) return { ok: true, dryRun, refreshCamp, refreshFibre, targets: 0 }
const recs = []
for (const j of targets) {
const a = _parseAddr(j.address); const camp = campingFor(campings, [j.address]) || null
const fm = (a.zip && a.civic) ? await fibreMatch(p, a.zip, a.civic, a.street) : null
recs.push({ name: j.name, first: String(j.address).split(',')[0], camp, pmid: fm && fm.pmid ? fm.pmid : null, flat: fm ? fm.flat : null, flon: fm ? fm.flon : null, confirmed: !!(fm && _streetConfirmed(fm.rue, a.street)), curLat: hasC(j.latitude) ? +j.latitude : null, curLon: hasC(j.longitude) ? +j.longitude : null })
}
const fresh = await placemarksLookup([...new Set(recs.map(r => r.pmid).filter(Boolean))])
const allNoms = new Set(); recs.forEach(r => { r.variants = lotVariants(r.first); r.variants.forEach(n => allNoms.add(n)) })
const byNom = allNoms.size ? await placemarksByNom([...allNoms]) : {}
const updates = []; const tally = {}; let none = 0, unchanged = 0
for (const r of recs) {
const pm = r.pmid != null ? fresh[String(r.pmid)] : null
const nm = (r.variants || []).map(x => byNom[x]).find(x => x && _inQC(x.lat, x.lon)) || null
let lat, lon, src
if (r.camp) { // camping : lot (nom) > placemarks_id frais > centroïde camping > fibre
if (nm) { lat = nm.lat; lon = nm.lon; src = 'placemarks_nom' }
else if (pm && _inQC(pm.lat, pm.lon)) { lat = pm.lat; lon = pm.lon; src = 'placemarks' }
else if (_inQC(r.camp.latitude, r.camp.longitude)) { lat = +r.camp.latitude; lon = +r.camp.longitude; src = 'camping' }
else if (_inQC(r.flat, r.flon)) { lat = +r.flat; lon = +r.flon; src = 'fibre' }
else { none++; continue }
} else { // hors camping : placemarks_id frais > fibre > nom de lot
if (pm && _inQC(pm.lat, pm.lon)) { lat = pm.lat; lon = pm.lon; src = 'placemarks' }
else if (_inQC(r.flat, r.flon)) { lat = +r.flat; lon = +r.flon; src = 'fibre_addr' }
else if (nm) { lat = nm.lat; lon = nm.lon; src = 'placemarks_nom' }
else { none++; continue }
}
lat = +(+lat).toFixed(6); lon = +(+lon).toFixed(6)
let moved = null
if (r.curLat != null) {
// Déjà coordonné : on n'ÉCRASE que si haute confiance (camping refresh comme avant ; sinon fibre RUE CONFIRMÉE) ET déplacement > 100 m.
const confident = r.camp ? true : (r.confirmed && (src === 'placemarks' || src === 'fibre_addr'))
if (!confident) { unchanged++; continue }
const d = _haversineM(r.curLat, r.curLon, lat, lon); if (d < 100) { unchanged++; continue } moved = Math.round(d)
}
updates.push({ name: r.name, lat, lon, src, moved_m: moved, addr: r.first }); tally[src] = (tally[src] || 0) + 1
}
let written = 0
if (!dryRun) { for (const u of updates) { const rr = await erp.update('Dispatch Job', u.name, { latitude: u.lat, longitude: u.lon }).catch(() => ({ ok: false })); if (rr && rr.ok) written++ } }
return { ok: true, dryRun, refreshCamp, refreshFibre, targets: targets.length, resolvable: updates.length, by_src: tally, unresolved: none, unchanged, written, sample: updates.slice(0, 14) }
}
// Résolveur de coords UNIFIÉ (algorithme développé) — le plus logique/autoritaire d'abord. `camp` = entrée camping_registry
// (si l'adresse est un terrain de camping) → ordre SPÉCIFIQUE : le LOT (placemarks.nom) est la source la plus précise ET
// fiable pour un camping ; les coords delivery de camping sont souvent erronées (autre lot/résidence du compte) → en DERNIER.
// Camping : nom(lot) > placemarks_id frais > centroïde camping > fibre > delivery. Sinon : placemarks_id > delivery > fibre > nom.
async function resolveDevCoords (p, { pmid, dlat, dlon, address, camp } = {}) {
const nomHit = async () => { const noms = lotVariants(String(address || '').split(',')[0]); if (!noms.length) return null; const bn = await placemarksByNom(noms); const h = noms.map(n => bn[n]).find(x => x && _inQC(x.lat, x.lon)); return h ? { lat: h.lat, lon: h.lon, src: 'placemarks_nom' } : null }
const pmHit = async () => { if (!pmid) return null; const f = await placemarksLookup([pmid]); const c = f[String(pmid)]; return (c && _inQC(c.lat, c.lon)) ? { lat: c.lat, lon: c.lon, src: 'placemarks' } : null }
// AUTORITÉ #1 — base INFRASTRUCTURE (fibre) pour l'adresse EXACTE (zip+civique+RUE CONFIRMÉE). PRIME sur la delivery, qui peut
// pointer une ANCIENNE/autre delivery du compte (client déménagé) → corrige les RUES NEUVES absentes du RQA (ex. Rue Elsie, Huntingdon).
if (p && !camp) {
const a0 = _parseAddr(address)
if (a0.zip && a0.civic && a0.street) {
const fm0 = await fibreMatch(p, a0.zip, a0.civic, a0.street)
if (fm0 && _streetConfirmed(fm0.rue, a0.street)) {
if (fm0.pmid) { const f0 = await placemarksLookup([fm0.pmid]); const c0 = f0[String(fm0.pmid)]; if (c0 && _inQC(c0.lat, c0.lon)) return { lat: c0.lat, lon: c0.lon, src: 'fibre_placemarks' } }
if (_inQC(fm0.flat, fm0.flon)) return { lat: +fm0.flat, lon: +fm0.flon, src: 'fibre' }
}
}
}
if (camp) { const nh = await nomHit(); if (nh) return nh; const ph = await pmHit(); if (ph) return ph; if (_inQC(camp.latitude, camp.longitude)) return { lat: +camp.latitude, lon: +camp.longitude, src: 'camping' } }
else { const ph = await pmHit(); if (ph) return ph; if (_inQC(dlat, dlon)) return { lat: +dlat, lon: +dlon, src: 'delivery' } }
const a = _parseAddr(address)
if (p && a.zip && a.civic) {
const fm = await fibreMatch(p, a.zip, a.civic, a.street)
if (fm) {
if (fm.pmid) { const f2 = await placemarksLookup([fm.pmid]); const c2 = f2[String(fm.pmid)]; if (c2 && _inQC(c2.lat, c2.lon)) return { lat: c2.lat, lon: c2.lon, src: 'fibre_placemarks' } }
if (_inQC(fm.flat, fm.flon)) return { lat: +fm.flat, lon: +fm.flon, src: 'fibre' }
}
}
if (camp) { if (_inQC(dlat, dlon)) return { lat: +dlat, lon: +dlon, src: 'delivery' } } // camping : delivery en TOUT dernier recours
else { const nh = await nomHit(); if (nh) return nh } // non-camping : nom de lot en dernier
return null
}
// Recherche/vérification d'un ticket legacy par n° (ex. 253061) : données F (sujet, compte, adresse de service), match
// téléphone si sans compte, et le Dispatch Job lié. Lecture seule. → résumé pour la recherche OPS « par n° de ticket ».
async function ticketLookup (id) {
const p = pool(); if (!p) return { ok: false, error: 'legacy DB indispo' }
const tid = parseInt(id, 10); if (!tid) return { ok: false, error: 'n° de ticket invalide' }
let t; try { const [r] = await p.query('SELECT id, account_id, delivery_id, subject, due_date, assign_to, status, dept_id FROM ticket WHERE id=?', [tid]); t = r[0] } catch (e) { return { ok: false, error: e.message } }
if (!t) return { ok: true, found: false, ticket_id: tid }
let dv = null; if (t.delivery_id) { try { const [d] = await p.query('SELECT id,address1,city,zip,latitude,longitude,placemarks_id FROM delivery WHERE id=?', [t.delivery_id]); dv = d[0] || null } catch (e) {} }
let acc = null; if (t.account_id) { try { const [a] = await p.query('SELECT id, first_name, last_name, company FROM account WHERE id=?', [t.account_id]); acc = a[0] || null } catch (e) {} }
let phone = null; if (!t.account_id || !dv) { try { const pm = await accountByPhone(p, t.subject); if (pm) phone = { account_id: pm.account_id, customer: pm.customer_name, address: [pm.dv.address1, pm.dv.city, pm.dv.zip].filter(Boolean).join(', ') } } catch (e) {} }
let dj = null; try { const x = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(tid)]], fields: ['name', 'status', 'latitude', 'longitude', 'address', 'assigned_tech', 'customer', 'scheduled_date'], limit: 1 }); dj = x[0] || null } catch (e) {}
return { ok: true, found: true, ticket: { id: t.id, subject: decodeEntities(t.subject), status: t.status, account_id: t.account_id || 0, delivery_id: t.delivery_id || 0, assign_to: t.assign_to || 0 }, account: acc, service_address: dv, phone_match: phone, dispatch_job: dj }
}
// BACKFILL de la trace tech perdue : les Dispatch Jobs issus du pont SANS assigned_tech (annulés/terminés « Non
// assigné » par l'ancien comportement) sont re-mappés depuis F. Le tech = assign_to FINAL du ticket F (qui l'a
// eu/faite), repli sur closed_by. Si le ticket F est fermé → statut Completed. Récupère « qui a fait quoi » a posteriori.
async function backfillTechTrace ({ dryRun = true } = {}) {
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
const all = await erp.list('Dispatch Job', {
filters: [['legacy_ticket_id', '!=', '']],
fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'scheduled_date', 'subject'], limit: 8000,
})
const jobByTk = new Map(); const ids = []
for (const j of (all || [])) {
if (j.assigned_tech) continue // déjà tracé — ne pas toucher
const id = String(j.legacy_ticket_id); const n = parseInt(id, 10); if (!n) continue
jobByTk.set(id, j); ids.push(n)
}
if (!ids.length) return { ok: true, dryRun, candidates: 0, updated: 0, by_tech: {}, sample: [] }
const staffToTech = await loadStaffToTech()
const [rows] = await p.query('SELECT id, assign_to, closed_by, status, due_date FROM ticket WHERE id IN (?)', [ids])
let updated = 0; const byTech = {}; const changes = []
for (const r of (rows || [])) {
const j = jobByTk.get(String(r.id)); if (!j) continue
const at = parseInt(r.assign_to, 10) || 0
const finalStaff = (at > 0 && at !== TARGO_TECH_STAFF_ID) ? at : (parseInt(r.closed_by, 10) || 0)
const tech = staffToTech[finalStaff]; if (!tech) continue // resté au pool / tech inconnu → on ne devine pas
const patch = { assigned_tech: tech }
if (r.status === 'closed') patch.status = 'Completed'
if (Number(r.due_date) > 0) { try { patch.scheduled_date = new Date(Number(r.due_date) * 1000).toISOString().slice(0, 10) } catch (e) {} }
byTech[tech] = (byTech[tech] || 0) + 1
if (changes.length < 60) changes.push({ job: j.name, ticket: String(r.id), tech, status: patch.status || j.status, date: patch.scheduled_date || j.scheduled_date, subject: (j.subject || '').slice(0, 44) })
if (!dryRun) { try { await erp.update('Dispatch Job', j.name, patch); updated++; audit.record({ job: j.name, ticket: String(r.id), event: 'backfill', to: tech, actor: 'F', source: 'backfill' }) } catch (e) {} }
}
return { ok: true, dryRun, candidates: changes.length ? ids.length : 0, mapped: Object.values(byTech).reduce((a, b) => a + b, 0), updated, by_tech: byTech, sample: changes }
}
async function handle (req, res, method, path) {
try {
if (path === '/dispatch/legacy-sync/backfill-tech' && method === 'GET') return json(res, 200, await backfillTechTrace({ dryRun: true }))
if (path === '/dispatch/legacy-sync/backfill-tech' && method === 'POST') return json(res, 200, await backfillTechTrace({ dryRun: false }))
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
if (path === '/dispatch/legacy-sync/tech-tickets' && method === 'GET') { // jobs legacy ouverts assignés à UN tech (lecture seule)
const q = new URL(req.url, 'http://localhost').searchParams
let sid = q.get('staff'); const tech = q.get('tech')
if (!sid && tech) { const m = String(tech).match(/(\d+)$/); sid = m ? m[1] : '' }
if (!sid) return json(res, 400, { ok: false, error: 'staff ou tech requis' })
return json(res, 200, await legacyTechTickets(sid))
}
if (path === '/dispatch/legacy-sync/window-load' && method === 'GET') { // charge legacy datée par tech×jour (fenêtre affichée) — lecture seule
const q = new URL(req.url, 'http://localhost').searchParams
const start = q.get('start'); const days = q.get('days') || '7'
if (!start) return json(res, 400, { ok: false, error: 'start requis' })
return json(res, 200, await legacyWindowLoad(start, days))
}
if (path === '/dispatch/legacy-sync/ticket' && method === 'GET') { // recherche/vérif par n° de ticket legacy (ex. ?id=253061) — lecture seule
const id = new URL(req.url, 'http://localhost').searchParams.get('id')
if (!id) return json(res, 400, { ok: false, error: 'id requis' })
return json(res, 200, await ticketLookup(id))
}
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'GET') { // APERÇU (0 écriture). ?closed=1 inclut les fermés (récup.) ; ?all=1 = tous les tickets ouverts assignés (ignore la fenêtre) ; sinon ?lo=&hi= fenêtre due_date en jours.
const q = new URL(req.url, 'http://localhost').searchParams
return json(res, 200, await ingestAssigned({ dryRun: true, loDays: Number(q.get('lo')) || -7, hiDays: Number(q.get('hi')) || 30, includeClosed: q.get('closed') === '1', allDates: q.get('all') === '1' }))
}
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'POST') { // INGÈRE : crée les Dispatch Job assignés (terrain). ?closed=1 = fermés aussi (Completed) ; ?all=1 = tous les ouverts assignés (ignore la fenêtre due_date).
const q = new URL(req.url, 'http://localhost').searchParams
return json(res, 200, await ingestAssigned({ dryRun: false, loDays: Number(q.get('lo')) || -7, hiDays: Number(q.get('hi')) || 30, includeClosed: q.get('closed') === '1', allDates: q.get('all') === '1' }))
}
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
if (path === '/dispatch/legacy-sync/backfill-sl' && (method === 'GET' || method === 'POST')) { // backfill one-time : Service Location manquantes des Customers legacy. GET = dry-run (0 écriture) · POST = applique. ?limit= plafonne.
const u = new URL(req.url, 'http://localhost')
const limit = Math.max(0, Number(u.searchParams.get('limit')) || 0)
return json(res, 200, await backfillServiceLocations({ dryRun: method === 'GET', limit }))
}
if (path === '/dispatch/legacy-sync/backfill-issues' && (method === 'GET' || method === 'POST')) { // backfill one-time : Issue.customer manquant sur les tickets legacy déjà importés (→ ils remontent dans la fiche client). GET = dry-run (0 écriture) · POST = applique. ?limit= plafonne.
const u = new URL(req.url, 'http://localhost')
const limit = Math.max(0, Number(u.searchParams.get('limit')) || 0)
return json(res, 200, await backfillIssueCustomers({ dryRun: method === 'GET', limit }))
}
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 »
// Géoloc via la carte d'infra F (fibre→placemarks) : GET = aperçu (0 écriture) · POST = applique (Service Location + jobs sans coords). ?limit= ?jobs=0
if (path === '/dispatch/legacy-sync/geolocate-infra' && (method === 'GET' || method === 'POST')) {
const u = new URL(req.url, 'http://localhost')
return json(res, 200, await geolocateFromInfra({ dryRun: method === 'GET', limit: Math.min(8000, Math.max(1, Number(u.searchParams.get('limit')) || 1000)), jobs: u.searchParams.get('jobs') !== '0' }))
}
// Géoloc des Dispatch Jobs (incl. legacy F) par leur adresse — GET=aperçu / POST=applique. Écrit DJ.latitude/longitude.
if (path === '/dispatch/legacy-sync/geolocate-jobs' && (method === 'GET' || method === 'POST')) {
const u = new URL(req.url, 'http://localhost')
const refreshCamp = /^(1|true|on)$/i.test(u.searchParams.get('refreshCamp') || '') // upgrade campings déjà coordonnés (centroïde → lot)
const refreshFibre = /^(1|true|on)$/i.test(u.searchParams.get('refreshFibre') || '') // CORRIGE les jobs déjà coordonnés quand la fibre confirme une autre position (rues neuves mal placées)
return json(res, 200, await geolocateJobs({ dryRun: method === 'GET', refreshCamp, refreshFibre, limit: Math.min(8000, Math.max(1, Number(u.searchParams.get('limit')) || 3000)) }))
}
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/ticket-post' && method === 'POST') { // poster une note (privée) / réponse (publique) au fil d'un ticket
const b = await parseBody(req); if (!b.ticket || !String(b.msg || '').trim()) return json(res, 400, { ok: false, error: 'ticket + msg requis' })
return json(res, 200, await postTicketLegacy(b.ticket, b.msg, !!b.public, req.headers['x-authentik-email'] || ''))
}
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/reconcile-jobs' && method === 'GET') return json(res, 200, await reconcileLegacyJobs({})) // APERÇU : aligne DJ legacy actifs sur F (0 écriture)
if (path === '/dispatch/legacy-sync/reconcile-jobs' && method === 'POST') { // APPLIQUE (confirm=RECONCILE) : corrige tech / annule selon F. N'écrit QUE ERPNext. purgePool=1 = aussi annuler les F=pool (opt-in risqué).
const q = new URL(req.url, 'http://localhost').searchParams
return json(res, 200, await reconcileLegacyJobs({ confirm: q.get('confirm') || '', purgePool: q.get('purgePool') === '1' }))
}
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)) }
// P2 — résout (ou crée) la conversation comms liée à un ticket osTicket legacy. Lazy : appelé au moment de répondre / d'ouvrir le fil complet.
if (path === '/dispatch/legacy-sync/ticket-conversation' && method === 'GET') {
const tid = new URL(req.url, 'http://localhost').searchParams.get('ticket')
const th = await ticketThread(tid)
if (!th || !th.ok) return json(res, 200, { ok: false, error: (th && th.error) || 'ticket introuvable' })
let customer = null
try { if (th.contact && th.contact.account_id) { const cu = await erp.list('Customer', { filters: [['legacy_account_id', '=', String(th.contact.account_id)]], fields: ['name', 'customer_name'], limit: 1 }); if (cu && cu[0]) customer = cu[0] } } catch (e) {}
const c = require('./conversation').getOrCreateConvForLegacyTicket(tid, {
customer: customer ? customer.name : null,
customerName: (customer && customer.customer_name) || (th.contact && th.contact.name) || null,
email: th.contact && th.contact.email, phone: th.contact && th.contact.phone, subject: th.subject,
})
if (!c) return json(res, 200, { ok: false, error: 'ticket invalide' })
return json(res, 200, { ok: true, convToken: c.token, channel: c.channel || null, contact: th.contact })
}
// GÉNÉRALISÉ par JOB (marche pour osTicket ET ERPNext-natif) — c'est ce qu'utilise la feuille de Planif.
if (path === '/dispatch/legacy-sync/job-thread' && method === 'GET') { const jb = new URL(req.url, 'http://localhost').searchParams.get('job'); return json(res, 200, await jobThread(jb)) }
if (path === '/dispatch/legacy-sync/job-conversation' && method === 'GET') {
const jobName = new URL(req.url, 'http://localhost').searchParams.get('job')
if (!jobName) return json(res, 200, { ok: false, error: 'job requis' })
let job
try { const jl = await erp.list('Dispatch Job', { filters: [['name', '=', jobName]], fields: ['name', 'customer', 'legacy_ticket_id', 'subject'], limit: 1 }); job = jl && jl[0] } catch (e) { return json(res, 200, { ok: false, error: e.message }) }
if (!job) return json(res, 200, { ok: false, error: 'job introuvable' })
const convMod = require('./conversation')
// 1) ticket osTicket → contact + conv legacy
if (job.legacy_ticket_id) {
const th = await ticketThread(job.legacy_ticket_id)
if (th && th.ok) {
let customer = null
try { if (th.contact && th.contact.account_id) { const cu = await erp.list('Customer', { filters: [['legacy_account_id', '=', String(th.contact.account_id)]], fields: ['name', 'customer_name'], limit: 1 }); customer = cu && cu[0] } } catch (e) {}
const c = convMod.getOrCreateConvForLegacyTicket(job.legacy_ticket_id, { customer: customer ? customer.name : job.customer, customerName: (customer && customer.customer_name) || (th.contact && th.contact.name), email: th.contact && th.contact.email, phone: th.contact && th.contact.phone, subject: th.subject || job.subject })
if (c) return json(res, 200, { ok: true, convToken: c.token, channel: c.channel || null, contact: th.contact, source: 'osticket' })
}
}
// 2) ERPNext-natif → contact depuis la fiche client
if (job.customer) {
let cust = null
try { const cl = await erp.list('Customer', { filters: [['name', '=', job.customer]], fields: ['name', 'customer_name', 'email_id', 'mobile_no'], limit: 1 }); cust = cl && cl[0] } catch (e) {}
const contact = { name: (cust && cust.customer_name) || null, email: (cust && cust.email_id) || null, phone: (cust && cust.mobile_no) || null, customer: job.customer }
if (!contact.email && !contact.phone) return json(res, 200, { ok: false, error: 'fiche client sans courriel ni téléphone' })
const c = convMod.getOrCreateConvForJob(job.name, { customer: job.customer, customerName: contact.name, email: contact.email, phone: contact.phone, subject: job.subject })
if (c) return json(res, 200, { ok: true, convToken: c.token, channel: c.channel || null, contact, source: 'erpnext' })
}
return json(res, 200, { ok: false, error: 'aucun contact client (ni ticket osTicket ni fiche ERPNext)' })
}
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) })
}
}
// ─── Réconciliation F→OPS : aligner les Dispatch Job legacy ACTIFS sur la vérité de F (autoritaire) ───
// Pour chaque DJ actif (open/assigned/in_progress) portant un legacy_ticket_id, on lit l'état RÉEL dans F (action pont
// `ticket_status`) puis : F fermé/résolu → ANNULE le DJ ; F renvoyé au pool (Tech Targo) ou à personne → ANNULE ;
// F réassigné à un AUTRE tech terrain → CORRIGE `assigned_tech` (+ scheduled_date = due_date F) ; F donné à un non-tech
// (bureau) → ANNULE (pas de bloc terrain pour du bureau) ; F = OPS → rien. N'ÉCRIT QUE ERPNext (jamais F). Aperçu par défaut.
async function reconcileLegacyJobs (opts = {}) {
const apply = opts.confirm === 'RECONCILE'
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const decode = decodeEntities
const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 800 })
const techToStaff = {}; const staffToTech = {}
for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (!m) continue; const s = Number(m[1]); techToStaff[t.technician_id] = s; if (s >= 2 && s !== TARGO_TECH_STAFF_ID) staffToTech[s] = t.technician_id }
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'in_progress']]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'scheduled_date', 'status', 'subject'], limit: 5000 })
const plan = { reassign: [], cancel_closed: [], cancel_pool: [], cancel_office: [], agree: [], not_in_f: [] }
const ids = [...new Set((djs || []).map(d => String(d.legacy_ticket_id)).filter(Boolean))]
const fState = {}
for (let i = 0; i < ids.length; i += 400) {
const chunk = ids.slice(i, i + 400)
try { const w = await legacyWrite({ action: 'ticket_status', ids: chunk.join(',') }); const rows = (w && w.data && Array.isArray(w.data.rows)) ? w.data.rows : []; for (const r of rows) fState[String(r.id)] = r } catch (e) { /* lot pont en échec → ces tickets resteront en not_in_f (prudence : on ne touche pas) */ }
}
for (const j of (djs || [])) {
const tid = String(j.legacy_ticket_id)
const f = fState[tid]
const curStaff = techToStaff[j.assigned_tech]
const subj = decode(j.subject || '').slice(0, 50)
if (!f) { plan.not_in_f.push({ job: j.name, tid, subj }); continue }
const fStaff = Number(f.assign_to) || 0
const open = String(f.status || '').toLowerCase() === 'open'
if (!open) { plan.cancel_closed.push({ job: j.name, tid, subj, f_status: f.status }); continue }
if (fStaff === TARGO_TECH_STAFF_ID || fStaff < 2) {
if (!j.assigned_tech) { plan.agree.push({ job: j.name, tid }); continue } // DJ NON assigné + F au pool = backlog COHÉRENT (les deux au pool) → on laisse
plan.cancel_pool.push({ job: j.name, tid, subj }); continue // DJ assigné à un tech mais F au pool → vraie divergence (stagé non publié OU périmé)
}
if (fStaff === curStaff) { plan.agree.push({ job: j.name, tid }); continue }
const fTech = staffToTech[fStaff]
if (!fTech) { plan.cancel_office.push({ job: j.name, tid, subj, f_staff: fStaff }); continue }
const iso = f.due_date ? new Date(Number(f.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : (j.scheduled_date || null)
plan.reassign.push({ job: j.name, tid, subj, from: j.assigned_tech || '(aucun)', to: fTech, sched: iso })
}
const counts = { active_total: (djs || []).length, reassign: plan.reassign.length, cancel_closed: plan.cancel_closed.length, cancel_pool: plan.cancel_pool.length, cancel_office: plan.cancel_office.length, agree: plan.agree.length, not_in_f: plan.not_in_f.length }
const samples = { reassign: plan.reassign.slice(0, 10), cancel_closed: plan.cancel_closed.slice(0, 10), cancel_pool: plan.cancel_pool.slice(0, 10), cancel_office: plan.cancel_office.slice(0, 10), not_in_f: plan.not_in_f.slice(0, 10) }
if (!apply) return { ok: true, apply: false, counts, samples, note: 'cancel_pool n\'est PAS appliqué par défaut (peut être une assignation OPS stagée non publiée) — opt-in purgePool=1 requis.' }
let done = 0; const errs = []
// SÛRS : F dit explicitement fermé / bureau → on annule le DJ. cancel_pool EXCLU par défaut (ambigu staging vs périmé).
const toCancel = [...plan.cancel_closed, ...plan.cancel_office]
if (opts.purgePool) toCancel.push(...plan.cancel_pool) // OPT-IN EXPLICITE : F=pool → annule (RISQUE : efface une assignation OPS non encore poussée dans F)
for (const c of toCancel) {
try { await erp.update('Dispatch Job', c.job, { status: 'Cancelled' }) } catch (e) { errs.push({ job: c.job, action: 'cancel', err: String(e.message || e) }) }
if (++done % 25 === 0) await sleep(200)
}
for (const r of plan.reassign) {
const patch = { assigned_tech: r.to }; if (r.sched) patch.scheduled_date = r.sched
try { await erp.update('Dispatch Job', r.job, patch) } catch (e) { errs.push({ job: r.job, action: 'reassign', err: String(e.message || e) }) }
if (++done % 25 === 0) await sleep(200)
}
return { ok: true, apply: true, applied: done, cancelled: toCancel.length, reassigned: plan.reassign.length, pool_held: opts.purgePool ? 0 : plan.cancel_pool.length, error_count: errs.length, errors: errs.slice(0, 50), counts }
}
// ─── BACKFILL ONE-TIME : Service Location manquantes des Customers legacy ───────────────────────────
// Contexte : pendant la fenêtre de déploiement du 2026-07-08, la création de Service Location échouait
// (bug `address_validation_status:'review'` rejeté par ERPNext ; corrigé → 'pending'). De plus, le
// court-circuit d'`ingestAssignedImpl` (findExisting AVANT buildJob) fait que le pont NE re-tentera PLUS
// la SL des customers dont le Dispatch Job existe déjà (tech + coords). Cette passe DÉDIÉE, indépendante
// du chemin chaud, crée la Service Location manquante pour chaque Customer legacy qui n'en a aucune.
//
// Idempotent : on ne cible QUE les customers SANS Service Location (l'ensemble des SL existantes est relu
// à chaque exécution → re-run = 0 doublon). Adresse dérivée du legacy : delivery (adresse de SERVICE,
// coords préférées) > adresse de facturation du compte. Batché/throttlé (ERPNext = socket hang up sous rafale).
// Dry-run par défaut (0 écriture) → renvoie les compteurs ; POST/force pour appliquer.
async function backfillServiceLocations ({ dryRun = true, limit = 0, throttleMs = 150 } = {}) {
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
// 1) Tous les Customers legacy (legacy_account_id renseigné), paginés. listRaw → on distingue « page vide » d'une ERREUR.
const customers = []
for (let start = 0; ; start += 500) {
const r = await erp.listRaw('Customer', { filters: [['legacy_account_id', '>', 0]], fields: ['name', 'customer_name', 'legacy_account_id'], limit: 500, start })
if (!r.ok) return { ok: false, error: 'Customer list: ' + r.error, at_start: start }
customers.push(...r.rows)
if (r.rows.length < 500) break
}
// 2) Ensemble des Customers qui possèdent DÉJÀ ≥1 Service Location (idempotence). Paginé intégralement.
const withSL = new Set()
for (let start = 0; ; start += 500) {
const r = await erp.listRaw('Service Location', { filters: [['customer', 'is', 'set']], fields: ['name', 'customer'], limit: 500, start })
if (!r.ok) return { ok: false, error: 'Service Location list: ' + r.error, at_start: start }
for (const s of r.rows) if (s.customer) withSL.add(s.customer)
if (r.rows.length < 500) break
}
// 3) Candidats = Customers legacy SANS aucune Service Location.
let candidates = customers.filter(c => !withSL.has(c.name) && Number(c.legacy_account_id) > 0)
const legacyCustomers = customers.length
const missingSL = candidates.length
if (limit > 0) candidates = candidates.slice(0, limit)
// 4) Résout l'adresse legacy des comptes candidats (delivery préférée, sinon facturation) par lots.
const acctIds = [...new Set(candidates.map(c => Number(c.legacy_account_id)).filter(Boolean))]
const deliv = {} // account_id -> meilleur delivery {id,address1,city,zip,lat,lon}
const bill = {} // account_id -> facturation {address1,city,zip}
for (let i = 0; i < acctIds.length; i += 500) {
const chunk = acctIds.slice(i, i + 500)
// Meilleur delivery par compte = coords valides d'abord (comme la jointure COALESCE de fetchTargoTickets), puis le plus récent.
const [drows] = await p.query(
'SELECT id, account_id, address1, city, zip, latitude AS lat, longitude AS lon FROM delivery WHERE account_id IN (?) ORDER BY account_id, (latitude IS NOT NULL AND ABS(latitude) > 1) DESC, id DESC',
[chunk])
for (const d of drows) if (!(d.account_id in deliv)) deliv[d.account_id] = d // 1re ligne par compte = la meilleure
const [arows] = await p.query('SELECT id, address1, city, zip FROM account WHERE id IN (?)', [chunk])
for (const a of arows) bill[a.id] = a
}
// 5) Plan : pour chaque candidat, choisir delivery (service) > facturation. Sans adresse exploitable → non traitable.
const plan = []; const noAddr = []
for (const c of candidates) {
const acct = Number(c.legacy_account_id)
const d = deliv[acct]; const b = bill[acct]
let rec = null
if (d && String(d.address1 || '').trim()) rec = { src: 'delivery', line: d.address1, city: d.city, zip: d.zip, lat: d.lat, lon: d.lon, deliveryId: d.id }
else if (b && String(b.address1 || '').trim()) rec = { src: 'billing', line: b.address1, city: b.city, zip: b.zip, lat: null, lon: null, deliveryId: null }
if (!rec) { noAddr.push({ customer: c.name, account_id: acct }); continue }
plan.push({ customer: c.name, customer_name: c.customer_name, account_id: acct, ...rec, has_coords: !!coord(rec.lat, rec.lon) })
}
const srcTally = plan.reduce((m, x) => { m[x.src] = (m[x.src] || 0) + 1; return m }, {})
const summary = { ok: true, dry_run: dryRun, legacy_customers: legacyCustomers, missing_sl: missingSL, scanned: candidates.length, creatable: plan.length, no_address: noAddr.length, by_source: srcTally, with_coords: plan.filter(x => x.has_coords).length }
if (dryRun) return { ...summary, samples: plan.slice(0, 15), no_address_samples: noAddr.slice(0, 15) }
// 6) Applique : createServiceLocation (helper canonique — address_validation_status:'pending', coords via coord(),
// legacy_delivery_id) en SÉQUENTIEL + throttle (ERPNext plante sous rafale). force:true = hors kill-switch.
let created = 0; let failed = 0; const errors = []
for (const x of plan) {
const sl = await createServiceLocation(x.customer, { line: x.line, city: x.city, zip: x.zip, lat: x.lat, lon: x.lon, deliveryId: x.deliveryId }, false, { force: true })
if (sl && sl.name) created++
else { failed++; if (errors.length < 50) errors.push({ customer: x.customer, account_id: x.account_id, src: x.src }) }
if (throttleMs) await sleep(throttleMs)
if (created % 25 === 0 && created) await sleep(400) // pause plus longue tous les 25 pour laisser respirer les workers Frappe
}
return { ...summary, created, failed, error_samples: errors }
}
// ─── BACKFILL ONE-TIME : Issue.customer manquant sur les tickets legacy DÉJÀ importés ──────────────
// Contexte : le chemin chaud (sync/ingestAssigned) ne relie l'Issue au client que lorsqu'il (re)traite un
// ticket ; mais le court-circuit `findExisting` d'ingestAssignedImpl saute les Dispatch Job « complets »
// (tech + coords) → leur Issue sous-jacente garde `customer` vide (Customer créé après la migration). Cette
// passe DÉDIÉE parcourt TOUS les Dispatch Job legacy portant un `customer` et assure Issue.customer :
// update si l'Issue existe (vide/différent), create minimale si elle manque (cf. ensureIssueCustomer).
// Idempotent (re-run = 0 écriture sur les déjà-liées). Dry-run par défaut (0 écriture) → renvoie les compteurs.
function backfillIssueCustomers (opts = {}) { const run = _syncLock.then(() => backfillIssueCustomersImpl(opts), () => backfillIssueCustomersImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run } // même verrou séquentiel (frappe_pg)
async function backfillIssueCustomersImpl ({ dryRun = true, limit = 0, throttleMs = 60 } = {}) {
if (!LINK_ISSUES) return { ok: false, error: 'LINK_ISSUES désactivé (LEGACY_DISPATCH_LINK_ISSUES=off)' }
const sleep = (ms) => new Promise(r => setTimeout(r, ms))
// 1) Tous les Dispatch Job legacy portant un customer, paginés. listRaw → distingue « page vide » d'une ERREUR.
const jobs = []
for (let start = 0; ; start += 500) {
const r = await erp.listRaw('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['customer', 'is', 'set']], fields: ['name', 'legacy_ticket_id', 'customer', 'subject'], limit: 500, start })
if (!r.ok) return { ok: false, error: 'Dispatch Job list: ' + r.error, at_start: start }
jobs.push(...r.rows)
if (r.rows.length < 500) break
if (limit > 0 && jobs.length >= limit) break
}
const rows = limit > 0 ? jobs.slice(0, limit) : jobs
const totalJobs = rows.length
// 2) État des Issue par legacy_ticket_id (batché). On distingue Issue absente vs Issue à customer vide/différent.
const ids = [...new Set(rows.map(j => Number(j.legacy_ticket_id)).filter(Number.isFinite))]
const issByTk = new Map()
for (let i = 0; i < ids.length; i += 200) {
const chunk = ids.slice(i, i + 200)
const iss = await erp.list('Issue', { filters: [['legacy_ticket_id', 'in', chunk]], fields: ['name', 'legacy_ticket_id', 'customer'], limit: chunk.length + 10 })
for (const r of (iss || [])) issByTk.set(Number(r.legacy_ticket_id), r)
}
let alreadyLinked = 0, linked = 0, createdIssues = 0, missingIssue = 0, errors = 0
const samples = [], errSamples = []
for (const j of rows) {
const tk = Number(j.legacy_ticket_id); if (!Number.isFinite(tk)) continue
const iss = issByTk.get(tk)
try {
if (iss) {
if (iss.customer === j.customer) { alreadyLinked++; continue }
if (dryRun) { linked++; if (samples.length < 25) samples.push({ ticket: tk, issue: iss.name, from: iss.customer || '(vide)', to: j.customer, action: 'link' }); continue }
const r = await erp.update('Issue', iss.name, { customer: j.customer })
if (r && r.ok) { linked++; if (samples.length < 25) samples.push({ ticket: tk, issue: iss.name, to: j.customer, action: 'linked' }) }
else { errors++; if (errSamples.length < 20) errSamples.push({ ticket: tk, issue: iss.name, error: (r && r.error) || 'update' }) }
} else {
missingIssue++
if (dryRun) { createdIssues++; if (samples.length < 25) samples.push({ ticket: tk, customer: j.customer, action: 'create-issue' }); continue }
const doc = { subject: String(j.subject || ('Ticket #' + tk)).slice(0, 255), status: 'Open', customer: j.customer, company: ISSUE_COMPANY, legacy_ticket_id: tk }
const r = await erp.create('Issue', doc)
if (r && r.ok && r.name) { createdIssues++; if (samples.length < 25) samples.push({ ticket: tk, issue: r.name, customer: j.customer, action: 'created' }) }
else { errors++; if (errSamples.length < 20) errSamples.push({ ticket: tk, error: (r && r.error) || 'create' }) }
}
} catch (e) { errors++; if (errSamples.length < 20) errSamples.push({ ticket: tk, error: String((e && e.message) || e) }) }
if (!dryRun && throttleMs) await sleep(throttleMs)
}
return { ok: true, dryRun, jobs: totalJobs, already_linked: alreadyLinked, linked, created_issues: createdIssues, missing_issue: missingIssue, errors, sample: samples, error_samples: errSamples }
}
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests