'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, source_issue_linked: 0, parent_incident_linked: 0, depends_on_linked: 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, source_issue_linked: 0, parent_incident_linked: 0, depends_on_linked: 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', 'postal_code', '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])
  // AUTORITÉ #0 — point d'installation EXACT (table `fibre` liée au service de la delivery). Le drop réel du tech →
  // géofence + distances de dispatch les plus précis possibles. Prime sur delivery 0/0, centroïde camping et RQA.
  if (t.delivery_id) { const fx = await fibreByDelivery(pool(), t.delivery_id); if (fx) { payload.latitude = fx.lat; payload.longitude = fx.lon; coordSrc = fx.src } }
  // ALGORITHME DÉVELOPPÉ : placemarks_id frais / delivery / fibre rue-désambiguïsée / campings par nom de lot.
  if (!coordSrc) { 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
    const sc = coord(sl.latitude, sl.longitude)
    // ADRESSE DE SERVICE DU BILLET : l'adresse/coords viennent de la delivery legacy, qui peut être PÉRIMÉE (client
    // DÉMÉNAGÉ) ou viser une AUTRE propriété du client. La Service Location est appariée par la VILLE du ticket (=
    // l'adresse pour laquelle le billet a été créé). On écrase par la SL LIÉE UNIQUEMENT si la delivery correspond à
    // une AUTRE Service Location DU MÊME CLIENT (cas déménagement/multi-adresses, bug LEG-254850 : delivery = ancienne
    // adresse Saint-Louis, SL = nouvelle Franklin). Un site EXTERNE (ex. cabane à sucre) ne matche AUCUNE SL → on
    // GARDE l'adresse du billet. _slCache est déjà peuplé par resolveServiceLocation (ligne « let sl = … » ci-dessus).
    const delivCity = norm(svcAddr ? t.dv_city : t.city)
    const slList = (cust && _slCache.get(cust.name)) || []
    const ownOtherAddr = sl.city && delivCity && norm(sl.city) !== delivCity && slList.some(l => l.name !== sl.name && norm(l.city) === delivCity)
    if (sc && ownOtherAddr) {
      payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location_override'
      const slAddr = [sl.address_line, sl.city, sl.postal_code].filter(Boolean).join(', ')
      if (slAddr) payload.address = slAddr.slice(0, 140)
    } else if (!coordSrc && 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', 'source_issue', 'depends_on'], limit: 1 })
  return (r && r[0]) || null
}

// RELATIONS DURABLES-NATIVES (survivent à une coupure de F) : lie le Dispatch Job à SON Issue via le champ natif
// `source_issue` (au lieu de ne partager que `legacy_ticket_id`, une clé morte sans F). Fill-only + idempotent.
async function linkJobSourceIssue (jobName, curSourceIssue, issueName, dryRun) {
  if (!jobName || !issueName || curSourceIssue) return false
  if (dryRun) { _createStats.source_issue_linked++; return true }
  const r = await erp.update('Dispatch Job', jobName, { source_issue: issueName })
  if (r && r.ok) { _createStats.source_issue_linked++; return true }
  return false
}
// DÉPENDANCE NATIVE Job→Job depuis `ticket.waiting_for` (le ticket attend un AUTRE ticket) → champ natif `depends_on`.
// LIEN SEUL — PAS de passage auto en On Hold (la visibilité au pool reste gérée par le dispatch existant). Fill-only + idempotent.
async function linkJobDependsOn (jobName, curDependsOn, waitsOnJobName, dryRun) {
  if (!jobName || !waitsOnJobName || curDependsOn || jobName === waitsOnJobName) return false
  if (dryRun) { _createStats.depends_on_linked++; return true }
  const r = await erp.update('Dispatch Job', jobName, { depends_on: waitsOnJobName })
  if (r && r.ok) { _createStats.depends_on_linked++; return true }
  return false
}

// 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) 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', 'parent_incident'], limit: 1 })
    const iss = rows && rows[0]
    let issName = iss ? iss.name : ''
    let curParentIncident = iss ? iss.parent_incident : ''
    if (iss) {
      if (iss.customer !== custName) { // (re)lie — n'écrit que si vide/différent
        if (!dryRun) { const r = await erp.update('Issue', iss.name, { customer: custName }); if (!(r && r.ok)) log('ensureIssueCustomer: update échec Issue ' + iss.name + ' (ticket#' + legacyId + ')') }
        _createStats.issues_linked++
      }
    } else {
      // Issue absente → create minimale. issue_type/priority OMIS (liens seedés → risque LinkValidationError).
      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
      if (dryRun) { _createStats.issues_created++ } else {
        const r = await erp.create('Issue', doc)
        if (r && r.ok && r.name) { issName = r.name; _createStats.issues_created++ } else { log('ensureIssueCustomer: create échec ticket#' + legacyId + ' — ' + ((r && r.error) || 'create')); return null }
      }
    }
    // HIÉRARCHIE NATIVE (durable, survit à F) : ticket.parent → Issue.parent_incident (fill-only) + parent.is_incident.
    // Maintenu LIVE ici (avant : recalculé seulement par la migration batch). Résout l'Issue du parent par legacy_ticket_id.
    if (issName && Number(t.parent) > 0 && !curParentIncident) {
      const pr = await erp.list('Issue', { filters: [['legacy_ticket_id', '=', Number(t.parent)]], fields: ['name', 'is_incident'], limit: 1 })
      const parent = pr && pr[0]
      if (parent && parent.name) {
        if (!dryRun) { await erp.update('Issue', issName, { parent_incident: parent.name }); if (!parent.is_incident) await erp.update('Issue', parent.name, { is_incident: 1 }) }
        _createStats.parent_incident_linked++
      }
    }
    return { action: iss ? 'ok' : 'created', issue: issName }
  } 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 })
      let jobName = ex ? ex.name : ''; const jobSrcIssue = ex ? (ex.source_issue || '') : ''; const jobDependsOn = ex ? (ex.depends_on || '') : '' // relations natives (source_issue + depends_on)
      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++; jobName = r.name } else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) }
      }
      // Relie l'Issue au client (+ hiérarchie parent_incident) ; puis lie le DJ à SON Issue (source_issue = relation NATIVE durable).
      if (b.payload.customer) { const ir = await ensureIssueCustomer(t, b.payload.customer, { dryRun }); if (ir && ir.issue) await linkJobSourceIssue(jobName, jobSrcIssue, ir.issue, dryRun) }
      // Dépendance native : ticket.waiting_for → depends_on (Job du ticket attendu). Lien seul.
      if (Number(t.waiting_for) > 0 && jobName) { const wj = await findExisting(String(t.waiting_for)); if (wj && wj.name) await linkJobDependsOn(jobName, jobDependsOn, wj.name, 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, source_issue_linked: _createStats.source_issue_linked, parent_incident_linked: _createStats.parent_incident_linked, depends_on_linked: _createStats.depends_on_linked, 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)
      let jobName = ex ? ex.name : ''; const jobSrcIssue = ex ? (ex.source_issue || '') : ''; const jobDependsOn = ex ? (ex.depends_on || '') : '' // relations natives (source_issue + depends_on)
      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++; jobName = r.name; 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 (+ hiérarchie parent_incident) ; puis lie le DJ à SON Issue (source_issue = relation NATIVE durable).
      if (b.payload.customer) { const ir = await ensureIssueCustomer(t, b.payload.customer, { dryRun }); if (ir && ir.issue) await linkJobSourceIssue(jobName, jobSrcIssue, ir.issue, dryRun) }
      // Dépendance native : ticket.waiting_for → depends_on (Job du ticket attendu). Lien seul.
      if (Number(t.waiting_for) > 0 && jobName) { const wj = await findExisting(String(t.waiting_for)); if (wj && wj.name) await linkJobDependsOn(jobName, jobDependsOn, wj.name, 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, source_issue_linked: _createStats.source_issue_linked, parent_incident_linked: _createStats.parent_incident_linked, depends_on_linked: _createStats.depends_on_linked, 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, force = false } = {}) {
  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, conflicts = 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
    // GARDE ANTI-CLOBBER : F tient DÉJÀ un AUTRE vrai tech (≠ pool 3301). Le push en lot n'a AUCUN arbitrage de
    // récence → écraser risquerait d'effacer une assignation F plus récente (et d'aviser le mauvais tech).
    // → ignoré par défaut, visible dans l'aperçu ; force=1 pour écraser en connaissance de cause.
    const fAt = parseInt(tkrow.assign_to, 10) || 0
    if (!force && fAt > 0 && fAt !== TARGO_TECH_STAFF_ID) {
      conflicts++
      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 || '') + ')', action: '⚠ CONFLIT : F a déjà un vrai tech — ignoré (force=1 pour écraser)' })
      continue
    }
    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, conflicts, 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 }
}

// État d'assignation LIVE d'un ticket F — garde-fou « assign-time » du roster (fenêtre aveugle du miroir ~3 min).
// Lecture seule, 1 requête. assigned = pris par un VRAI tech (≠ pool 3301) et pas fermé.
async function ticketAssignState (legacyId) {
  const p = pool(); if (!p) return null
  const id = parseInt(String(legacyId || '').replace(/[^0-9]/g, ''), 10); if (!id) return null
  const [rows] = await p.query('SELECT t.id, t.status, t.assign_to, s.first_name, s.last_name FROM ticket t LEFT JOIN staff s ON s.id = t.assign_to WHERE t.id = ? LIMIT 1', [id])
  const r = rows && rows[0]; if (!r) return null
  const at = parseInt(r.assign_to, 10) || 0
  return {
    ticket: id, status: r.status, staff_id: at,
    staff_name: [r.first_name, r.last_name].filter(Boolean).join(' '),
    assigned: r.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID,
  }
}

// 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 _staleTimer = null
let _techHistTimer = 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, dueTime) {
  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) {} }
  // Heure MIROIR depuis F due_time (am→08:00, pm→13:00, HH:MM tel quel) : sans start_time le job n'occupe NI la
  // capacité NI les créneaux → « on peut encore choisir le même tech » (double-booking). 'day'/inconnu → pas d'heure.
  const stt = startTime(dueTime)
  if (stt) patch.start_time = stt
  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', 'start_time', '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, due_time 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
  let timed = 0; let fillBudget = 80 // remplissage start_time par passage, borné (écritures séquentielles frappe_pg)
  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, r.due_date, staffToTech, r.due_time)) {
        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
    }
    // (1b) REMPLISSAGE start_time (fill-only, idempotent) : job assigné SANS heure alors que F a un due_time
    //      exploitable (am/pm/HH:MM). Rattrape le stock miroré AVANT ce fix — sans heure, le job n'occupe ni la
    //      capacité ni les créneaux (double-booking). Ne touche JAMAIS une heure déjà posée par le répartiteur.
    if (fillBudget > 0 && j.assigned_tech && !j.start_time && cur.status !== 'closed') {
      const stt = startTime(r.due_time)
      if (stt) { try { await erp.update('Dispatch Job', j.name, { start_time: stt }); j.start_time = stt; timed++; fillBudget-- } catch (e) {} }
    }
    // (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, r.due_date, staffToTech, r.due_time)) 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, timed, changes: changes.length }
  return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, mirrored, timed, 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)
  // AUTO-CORRECTION des coords PÉRIMÉES : les coords erronées (posées par un ancien géocodage, avant que la fibre indexe
  // l'adresse) NE se corrigeaient jamais toutes seules — la sync ne remplit que les coords MANQUANTES (0,0/NULL). Ici on
  // relance geolocateJobs({refreshFibre}) qui RE-vérifie les jobs DÉJÀ coordonnés et n'écrase QUE si la fibre CONFIRME la
  // rue ET que le déplacement > 100 m (cf. geolocateJobs) → les vieux pins décalés se soignent seuls. 1 tick sur N (~horaire).
  // Désactivable via LEGACY_DISPATCH_REFRESH_FIBRE_AUTO=off.
  const refreshFibreAuto = !/^(off|0|false|no)$/i.test(String(process.env.LEGACY_DISPATCH_REFRESH_FIBRE_AUTO || ''))
  const refreshFibreEvery = Math.max(1, Number(process.env.LEGACY_DISPATCH_REFRESH_FIBRE_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)
    const runRefresh = refreshFibreAuto && (_tickN % refreshFibreEvery === 0); _tickN++
    sync({ dryRun: false })
      .then(() => runAssigned ? ingestAssigned({ dryRun: false, allDates: true }) : null)
      .then(() => runRefresh ? geolocateJobs({ dryRun: false, refreshFibre: true }).then(r => { if (r && r.written) log(`legacy-dispatch-sync: auto-refresh fibre → ${r.written} coord(s) corrigée(s)`) }) : 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')`)
  // DIGEST « tickets périmés » (sans activité ≥N j) → nudge best-effort. OPT-IN (défaut OFF) : LEGACY_STALE_NUDGE ∈ {on,1,true}.
  // Cadence LEGACY_STALE_NUDGE_HOURS (défaut 24). Inerte si aucun webhook configuré (alertWebhook log+skip).
  if (['on', '1', 'true'].includes(String(process.env.LEGACY_STALE_NUDGE || '').toLowerCase())) {
    const staleHours = Math.max(1, Number(process.env.LEGACY_STALE_NUDGE_HOURS) || 24)
    const stick = () => { staleNudge().then(r => { if (r && (r.fresh || !r.ok)) log('stale-nudge:', JSON.stringify(r)) }).catch(e => log('stale-nudge error:', e.message)) }
    setTimeout(stick, 120 * 1000)
    _staleTimer = setInterval(stick, staleHours * 3600 * 1000)
    log(`stale-nudge: digest actif (toutes les ${staleHours}h)`)
  }
  // HISTORIQUE D'ASSIGNATION PAR TECH : tick incrémental (avance le watermark → capte les nouveaux changements).
  // Défaut ON (LEGACY_TECH_HISTORY=off pour couper). Petit lot/tick (le backfill initial complet = one-shot séparé).
  if (String(process.env.LEGACY_TECH_HISTORY || 'on').toLowerCase() !== 'off') {
    const thMin = Math.max(2, Number(process.env.LEGACY_TECH_HISTORY_MIN) || 15)
    const thBatches = Math.max(1, Number(process.env.LEGACY_TECH_HISTORY_TICK_BATCHES) || 8)
    let _thInFlight = false
    const thtick = () => { if (_thInFlight) return; _thInFlight = true; techHistoryBackfill({ maxBatches: thBatches }).then(r => { if (r && r.inserted) log('tech-history:', JSON.stringify({ inserted: r.inserted, matched: r.matched, wm: r.watermark })) }).catch(e => log('tech-history error:', e.message)).finally(() => { _thInFlight = false }) }
    setTimeout(thtick, 180 * 1000)
    _techHistTimer = setInterval(thtick, thMin * 60 * 1000)
    log(`tech-history: ledger d'assignation actif (toutes les ${thMin} min)`)
  }
}
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } if (_staleTimer) { clearInterval(_staleTimer); _staleTimer = null } if (_techHistTimer) { clearInterval(_techHistTimer); _techHistTimer = 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, writtenSLsql = 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) || ''))) {
        // SL SANS `customer` (fibre DISPONIBLE, pas encore de client) = cas NORMAL, PAS orphelin. Le PUT ERPNext re-valide
        // tout le doc → MandatoryError. On écrit alors les coords en DIRECT via le pool PG (même DB ERPNext que address-db) :
        // les coords n'ont AUCUNE dénormalisation dépendante, donc bypass validation = sûr (idem backfill batch 2026-07-14).
        try { const rr = await addrdb.pool().query('UPDATE "tabService Location" SET latitude=$1, longitude=$2, modified=now() WHERE name=$3', [u.lat, u.lon, u.name]); if (rr && rr.rowCount) writtenSLsql++; else blockedNoCustomer++ } catch (e) { blockedNoCustomer++; log('geoloc SL SQL ' + u.name + ': ' + e.message) }
      } else { failedSL++; log('geoloc SL ' + u.name + ': ' + ((r && r.error) || 'échec')) }
    }
    // Propager les coords écrites (REST ou SQL) vers gf_latitude/gf_longitude des Address liées encore à 0 (source géo canonique du collapse).
    try { await addrdb.pool().query('UPDATE "tabAddress" a SET gf_latitude=sl.latitude, gf_longitude=sl.longitude, modified=now() FROM "tabService Location" sl WHERE sl.linked_address=a.name AND sl.latitude IS NOT NULL AND abs(sl.latitude)>0.01 AND (a.gf_latitude IS NULL OR abs(coalesce(a.gf_latitude,0))<0.01) AND sl.name = ANY($1)', [updates.map(u => u.name)]) } catch (e) { log('geoloc gf_ propagate: ' + e.message) }
    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, written_sql_no_customer: writtenSLsql, 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] : ''
  // Le suffixe d'unité (« 3A ») doit être COLLÉ au numéro puis suivi d'un espace ; sinon le `\s*[A-Za-z]?`
  // mangeait le « R » de « Rue » (ex. « 4-3 Rue Des Cèdres » → street « ue Des Cèdres »).
  const street = first.replace(/^\s*\d+(?:-\d+)?[A-Za-z]?\s+/, '').trim()
  return { zip, civic, street }
}
// Un civique « A-B » est AMBIGU : bâtiment-unité (F indexe le terrain « A-B », ex. « 102-4 Rue Elsie ») OU unité-civique
// (le VRAI civique est le 2ᵉ nombre, ex. « 4-3 Rue Des Cèdres » = app. 4 au civique 3, F indexe « 3 »). On essaie les deux
// ordres, du plus précis au plus large, et la RUE tranche (cf. fibreMatch). Un civique simple garde le comportement historique.
function _civicCandidates (civic) {
  const c = String(civic || '').trim(); if (!c) return []
  const m = c.match(/^(\d+)-(\d+)$/); if (!m) return [{ pat: c + '%', kind: 'like' }]
  const [, a, b] = m
  return [
    { pat: a + '-' + b + '%', kind: 'like' }, // A-B : bâtiment-unité (comportement Elsie, historique)
    { pat: b + '-' + a + '%', kind: 'like' }, // B-A : ordre inverse stocké
    { pat: b, kind: 'exact' }, // B : unité-civique → civique = 2ᵉ nombre
    { pat: a, kind: 'exact' } //  A : repli (civique = 1ᵉ nombre, unité au 2ᵉ)
  ]
}
// Match fibre par zip+civique, DÉSAMBIGUÏSÉ par la rue (compare quand plusieurs civiques identiques). → ligne fibre ou null.
// Tolère l'ordre AMBIGU du civique « A-B » (bâtiment-unité vs unité-civique) via _civicCandidates : le candidat as-parsed
// garde le comportement historique ; chaque ordre ALTERNATIF n'est accepté que si la RUE concorde (jamais deviné).
async function fibreMatch (p, zip, civic, street) {
  const z = String(zip || '').replace(/\s/g, '').toUpperCase()
  const cands = _civicCandidates(civic)
  if (!z || !cands.length) return null
  const ns = _normStreet(street)
  const streetHit = (rows) => ns ? rows.find(r => { const rr = _normStreet(r.rue); return rr && (rr === ns || (rr.length >= 4 && (rr.includes(ns) || ns.includes(rr)))) }) : null
  for (let i = 0; i < cands.length; i++) {
    const cand = cands[i]
    const sql = "SELECT placemarks_id pmid, latitude flat, longitude flon, rue FROM fibre WHERE REPLACE(UPPER(zip),' ','')=? AND " +
      (cand.kind === 'exact' ? 'terrain=?' : 'terrain LIKE ?') + ' LIMIT 12'
    let rows = []
    try { [rows] = await p.query(sql, [z, cand.pat]) } catch (e) { return null }
    if (!rows.length) continue
    // Ordre ALTERNATIF (i>0) : n'accepter que si la RUE concorde — un civique inversé/partiel ne doit JAMAIS être deviné
    // sans confirmation de rue. Le candidat as-parsed (i===0) garde le comportement historique (row unique accepté).
    if (rows.length === 1) { if (i === 0 || !ns) return rows[0]; if (_streetConfirmed(rows[0].rue, street)) return rows[0]; continue }
    const m = streetHit(rows); if (m) return m
    // plusieurs civiques + aucune rue concordante → on NE devine pas (évite le mauvais « 95 Rue Principale » vs « Rang du Cinq »)
  }
  return null
}

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', 'legacy_ticket_id'], 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.
  // Sans coords : on cible (adresse OU ticket legacy → fibre-par-service possible). Déjà coordonné : re-vérif SEULEMENT si
  // demandé (refreshFibre = tick horaire, ou refreshCamp) — l'autorité #0 fibre-par-service (fx) est alors tentée et corrige
  // les pins imprécis liés à une fibre. On évite de re-requêter la fibre de CHAQUE job coordonné à chaque passage.
  const targets = jobs.filter(j => { const addr = String(j.address || '').trim(); if (!hasC(j.latitude)) return !!(addr || j.legacy_ticket_id); return refreshFibre || (refreshCamp && !!campingFor(campings, [addr])) }).slice(0, limit)
  if (!targets.length) return { ok: true, dryRun, refreshCamp, refreshFibre, targets: 0 }
  // Map legacy_ticket_id → delivery_id (1 requête) pour l'autorité #0 fibre-par-service (drop d'installation exact).
  const tids = [...new Set(targets.map(j => parseInt(j.legacy_ticket_id, 10)).filter(Boolean))]
  const dlvByTicket = new Map()
  if (tids.length) { try { const [tr] = await p.query('SELECT id, delivery_id FROM ticket WHERE id IN (?)', [tids]); for (const r of (tr || [])) dlvByTicket.set(String(r.id), r.delivery_id) } catch (e) {} }
  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
    const did = dlvByTicket.get(String(j.legacy_ticket_id))
    const fx = did ? await fibreByDelivery(p, did) : null // AUTORITÉ #0 : drop exact par service
    recs.push({ name: j.name, first: String(j.address || '').split(',')[0], camp, fx, 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.fx) { lat = r.fx.lat; lon = r.fx.lon; src = r.fx.src } // AUTORITÉ #0 : drop d'installation EXACT (fibre par service) — prime sur tout
    else 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. fibre-par-service (fx) = drop EXACT → toujours confiant, seuil serré
      // (30 m) car même une petite correction compte pour un géofence de 150-250 m. Sinon : camping refresh, ou fibre RUE CONFIRMÉE, seuil 100 m.
      const confident = r.fx ? true : (r.camp ? true : (r.confirmed && (src === 'placemarks' || src === 'fibre_addr')))
      if (!confident) { unchanged++; continue }
      const minMove = r.fx ? 30 : 100
      const d = _haversineM(r.curLat, r.curLon, lat, lon); if (d < minMove) { 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.
// AUTORITÉ #0 des coords : le point d'installation EXACT via la table `fibre` liée au SERVICE de la delivery
// (ticket.delivery_id → service.delivery_id → fibre.*_service_id). C'est le DROP physique réel où le tech intervient
// → la coord la plus précise possible (précède delivery 0/0, centroïde camping, RQA). Préfère le placemark d'infra si lié.
async function fibreByDelivery (p, deliveryId) {
  if (!p || !deliveryId) return null
  try {
    const [rows] = await p.query(
      `SELECT f.latitude flat, f.longitude flon, f.placemarks_id pmid, f.rue
         FROM service s
         JOIN fibre f ON (f.service_id = s.id OR f.internet_service_id = s.id OR f.manage_service_id = s.id OR f.telephone_service_id = s.id OR f.tele_service_id = s.id)
        WHERE s.delivery_id = ? AND f.latitude IS NOT NULL AND f.latitude <> 0
        ORDER BY f.id DESC LIMIT 1`, [deliveryId])
    const r = rows && rows[0]; if (!r) return null
    if (r.pmid) { const f = await placemarksLookup([r.pmid]); const c = f[String(r.pmid)]; if (c && _inQC(c.lat, c.lon)) return { lat: c.lat, lon: c.lon, src: 'fibre_service_placemarks' } }
    if (_inQC(r.flat, r.flon)) return { lat: +r.flat, lon: +r.flon, src: 'fibre_service' }
  } catch (e) { /* fibre/service indispo → sources suivantes */ }
  return null
}

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') { // APERÇU write-back tech → legacy (0 écriture) ; force=1 simule l'écrasement des conflits
      const force = new URL(req.url, 'http://localhost').searchParams.get('force') === '1'
      return json(res, 200, await pushAssignments({ dryRun: true, force }))
    }
    if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel ; force=1 écrase les conflits F
      const sp = new URL(req.url, 'http://localhost').searchParams
      return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify: sp.get('notify') !== '0', force: sp.get('force') === '1' }))
    }
    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/stale' && method === 'GET') { // tickets ouverts sans activité ≥N j (défaut 3) — natif, remplace le courriel « stale » de F. LECTURE SEULE. ?days= ?limit=
      const u = new URL(req.url, 'http://localhost'); return json(res, 200, await staleTickets({ days: u.searchParams.get('days'), limit: u.searchParams.get('limit') }))
    }
    if (path === '/dispatch/legacy-sync/stale-nudge' && (method === 'GET' || method === 'POST')) return json(res, 200, await staleNudge()) // déclenche le digest maintenant (best-effort webhook ; anti-spam persisté)
    if (path === '/dispatch/legacy-sync/publish-preview' && method === 'GET') { // APERÇU UNIFIÉ OPS→F (assignation+fermeture+horaire) — 0 écriture. ?job=<name> pour 1 seul job.
      const jb = new URL(req.url, 'http://localhost').searchParams.get('job') || ''; return json(res, 200, await publishPreview({ job: jb }))
    }
    if (path === '/dispatch/legacy-sync/publish-job' && (method === 'GET' || method === 'POST')) { // GET = PLAN (dry-run, 0 écriture) · POST = APPLIQUE vers F (assign/close/schedule). ?job= ?only=assign,close,schedule ?notify=0
      const u = new URL(req.url, 'http://localhost'); const jb = u.searchParams.get('job'); if (!jb) return json(res, 400, { ok: false, error: 'job requis' })
      return json(res, 200, await publishJob({ job: jb, dryRun: method === 'GET', actorEmail: req.headers['x-authentik-email'] || '', notify: u.searchParams.get('notify') !== '0', only: u.searchParams.get('only') || null }))
    }
    if (path === '/dispatch/legacy-sync/tech-history' && method === 'GET') { // ledger d'assignation par tech (PG). ?tech=TECH-<id> → détail · sans tech → récap par tech
      const u = new URL(req.url, 'http://localhost'); return json(res, 200, await techHistory({ tech: u.searchParams.get('tech') || '', limit: u.searchParams.get('limit') }))
    }
    if (path === '/dispatch/legacy-sync/tech-history/backfill' && (method === 'GET' || method === 'POST')) { // GET=dry-run · POST=applique. ?reset=1 repart de 0 · ?max=<batches> · ?batch=<taille>
      const u = new URL(req.url, 'http://localhost')
      return json(res, 200, await techHistoryBackfill({ dryRun: method === 'GET', reset: u.searchParams.get('reset') === '1', maxBatches: Number(u.searchParams.get('max')) || 40, batch: Number(u.searchParams.get('batch')) || 5000 }))
    }
    if (path === '/dispatch/legacy-sync/tech-history/provision-identities' && (method === 'GET' || method === 'POST')) { // GET=dry-run · POST=applique. Lie legacy_staff_id aux identités (crée si absente) pour faire converger le ledger. ?scope=ledger|active
      const u = new URL(req.url, 'http://localhost'); return json(res, 200, await provisionIdentities({ dryRun: method === 'GET', scope: u.searchParams.get('scope') || 'ledger' }))
    }
    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 }
}

// Backfill du lien NATIF durable Dispatch Job → Issue (`source_issue`) pour TOUS les jobs legacy (y compris ceux
// court-circuités par le sync). Résout l'Issue par legacy_ticket_id. Idempotent (ne touche que source_issue vide).
// C'est ce qui fait SURVIVRE la relation ticket↔job à une coupure de F (legacy_ticket_id devient une clé morte).
function backfillSourceIssue (opts = {}) { const run = _syncLock.then(() => backfillSourceIssueImpl(opts), () => backfillSourceIssueImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run }
async function backfillSourceIssueImpl ({ dryRun = true, limit = 0, throttleMs = 40 } = {}) {
  const sleep = (ms) => new Promise(r => setTimeout(r, ms))
  const jobs = []
  for (let start = 0; ; start += 500) {
    const r = await erp.listRaw('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['source_issue', 'is', 'not set']], fields: ['name', 'legacy_ticket_id'], 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 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'], limit: chunk.length + 10 })
    for (const r of (iss || [])) issByTk.set(Number(r.legacy_ticket_id), r)
  }
  let linked = 0, missingIssue = 0, errors = 0; const errSamples = []
  for (const j of rows) {
    const iss = issByTk.get(Number(j.legacy_ticket_id))
    if (!iss) { missingIssue++; continue }
    if (dryRun) { linked++; continue }
    try { const r = await erp.update('Dispatch Job', j.name, { source_issue: iss.name }); if (r && r.ok) linked++; else { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: (r && r.error) || 'update' }) } }
    catch (e) { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: String((e && e.message) || e) }) }
    if (throttleMs) await sleep(throttleMs)
  }
  return { ok: true, dryRun, jobs: rows.length, linked, missing_issue: missingIssue, errors, error_samples: errSamples }
}

// Backfill de la DÉPENDANCE NATIVE Job→Job (`depends_on`) depuis legacy `ticket.waiting_for` (le ticket attend un
// AUTRE ticket). LIEN SEUL (pas d'On Hold auto — visibilité pool inchangée). Idempotent (ne touche que depends_on vide).
function backfillDependsOn (opts = {}) { const run = _syncLock.then(() => backfillDependsOnImpl(opts), () => backfillDependsOnImpl(opts)); _syncLock = run.then(() => {}, () => {}); return run }
async function backfillDependsOnImpl ({ dryRun = true, throttleMs = 40 } = {}) {
  const sleep = (ms) => new Promise(r => setTimeout(r, ms))
  const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible sur le hub' }
  const [wrows] = await p.query('SELECT id, waiting_for FROM ticket WHERE waiting_for > 0')
  const waitMap = new Map(); for (const r of (wrows || [])) waitMap.set(Number(r.id), Number(r.waiting_for))
  if (!waitMap.size) return { ok: true, dryRun, legacy_pairs: 0, candidates: 0, linked: 0, missing_job: 0, errors: 0 }
  // Jobs candidats : depends_on vide + legacy_ticket_id présent, dont le ticket a un waiting_for.
  const cand = []
  for (let start = 0; ; start += 500) {
    const r = await erp.listRaw('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['depends_on', 'is', 'not set']], fields: ['name', 'legacy_ticket_id'], limit: 500, start })
    if (!r.ok) return { ok: false, error: 'Dispatch Job list: ' + r.error, at_start: start }
    for (const j of r.rows) if (waitMap.has(Number(j.legacy_ticket_id))) cand.push(j)
    if (r.rows.length < 500) break
  }
  // Résout le Job du ticket ATTENDU (par legacy_ticket_id).
  const waitingIds = [...new Set(cand.map(j => waitMap.get(Number(j.legacy_ticket_id))).filter(Number.isFinite))]
  const jobByTk = new Map()
  for (let i = 0; i < waitingIds.length; i += 200) {
    const chunk = waitingIds.slice(i, i + 200)
    const js = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', 'in', chunk.map(String)]], fields: ['name', 'legacy_ticket_id'], limit: chunk.length + 10 })
    for (const r of (js || [])) jobByTk.set(Number(r.legacy_ticket_id), r.name)
  }
  let linked = 0, missingJob = 0, errors = 0; const errSamples = []
  for (const j of cand) {
    const wjob = jobByTk.get(waitMap.get(Number(j.legacy_ticket_id)))
    if (!wjob || wjob === j.name) { missingJob++; continue }
    if (dryRun) { linked++; continue }
    try { const r = await erp.update('Dispatch Job', j.name, { depends_on: wjob }); if (r && r.ok) linked++; else { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: (r && r.error) || 'update' }) } }
    catch (e) { errors++; if (errSamples.length < 20) errSamples.push({ job: j.name, error: String((e && e.message) || e) }) }
    if (throttleMs) await sleep(throttleMs)
  }
  return { ok: true, dryRun, legacy_pairs: waitMap.size, candidates: cand.length, linked, missing_job: missingJob, errors, error_samples: errSamples }
}

// ─── TICKETS PÉRIMÉS (natif) : remplace le courriel « stale » de F ────────────────────────────────
// Tickets legacy OUVERTS sans activité depuis N jours (défaut 3). « activité » = ticket.last_update (epoch),
// qui ne bouge QUE sur un vrai événement F (message/réponse/statut/assignation) — nos lectures sont SELECT-only
// → signal PROPRE (pas faussé par la sync). LECTURE SEULE (0 écriture). Borné (ORDER BY last_update ASC LIMIT).
// buckets : sous-ensemble de {pool,tech,unassigned,other} à INCLURE (poussé en SQL → le LIMIT reste pertinent).
//   pool=Tech Targo (3301) · tech=staff mappé à un Dispatch Technician · unassigned=assign_to 0 · other=autre staff (bureau/asset).
// maxAgeDays : borne SUPÉRIEURE d'âge (exclut les tickets abandonnés depuis des années — bruit ≠ « vient de périmer »). 0 = pas de borne.
async function staleTickets ({ days = 3, limit = 500, buckets = null, maxAgeDays = 0 } = {}) {
  const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
  const d = Math.max(1, Math.min(90, Number(days) || 3))
  const lim = Math.max(1, Math.min(2000, Number(limit) || 500))
  const now = Math.floor(Date.now() / 1000)
  const cutoff = now - d * 86400
  const maxAge = Math.max(0, Number(maxAgeDays) || 0)
  const staffToTech = await loadStaffToTech()
  const techStaffIds = Object.keys(staffToTech).map(Number).filter(Boolean)
  const want = Array.isArray(buckets) ? buckets : (typeof buckets === 'string' && buckets.trim() ? buckets.split(',') : null)
  const wantSet = (want && want.length) ? new Set(want.map(s => String(s).trim()).filter(Boolean)) : null
  const where = ["t.status = 'open'", 't.last_update > 0', 't.last_update < ?']; const args = [cutoff]
  if (maxAge) { where.push('t.last_update > ?'); args.push(now - maxAge * 86400) }
  if (wantSet) {
    const ors = []
    if (wantSet.has('pool')) { ors.push('t.assign_to = ?'); args.push(TARGO_TECH_STAFF_ID) }
    if (wantSet.has('unassigned')) ors.push('t.assign_to = 0')
    if (wantSet.has('tech') && techStaffIds.length) { ors.push('t.assign_to IN (?)'); args.push(techStaffIds) }
    if (wantSet.has('other')) { ors.push('(t.assign_to > 0 AND t.assign_to NOT IN (?))'); args.push([TARGO_TECH_STAFF_ID, ...techStaffIds]) }
    if (!ors.length) return { ok: true, days: d, cutoff_iso: new Date(cutoff * 1000).toISOString(), total: 0, capped: false, buckets: { pool: 0, tech: 0, unassigned: 0, other: 0 }, tickets: [] }
    where.push('(' + ors.join(' OR ') + ')')
  }
  args.push(lim)
  const [rows] = await p.query(
    `SELECT t.id, t.subject, t.assign_to, t.last_update, t.due_date, t.priority, dd.name AS dept,
            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 ${where.join(' AND ')}
      ORDER BY t.last_update ASC LIMIT ?`, args)
  const ids = (rows || []).map(r => String(r.id))
  let djByTk = new Map()
  if (ids.length) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', 'in', ids]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'status'], limit: ids.length + 10 }); djByTk = new Map((djs || []).map(j => [String(j.legacy_ticket_id), j])) } catch (e) {} }
  const bucketCounts = { pool: 0, tech: 0, unassigned: 0, other: 0 }
  const tickets = (rows || []).map(r => {
    const at = parseInt(r.assign_to, 10) || 0
    let bucket, who
    if (at === TARGO_TECH_STAFF_ID) { bucket = 'pool'; who = 'Tech Targo (pool)' } else if (at === 0) { bucket = 'unassigned'; who = '(non assigné)' } else if (staffToTech[at]) { bucket = 'tech'; who = staffToTech[at] } else { bucket = 'other'; who = 'staff #' + at }
    bucketCounts[bucket]++
    const dj = djByTk.get(String(r.id)) || null
    return {
      ticket: String(r.id), subject: decodeEntities(r.subject), dept: r.dept || '', bucket, assignee: who,
      customer: r.company || [r.first_name, r.last_name].filter(Boolean).join(' ') || '', city: r.city || '',
      age_days: Math.floor((now - Number(r.last_update)) / 86400), last_activity: new Date(Number(r.last_update) * 1000).toISOString(),
      due: (r.due_date && Number(r.due_date) > 0) ? new Date(Number(r.due_date) * 1000).toISOString().slice(0, 10) : '', priority: Number(r.priority) || 0,
      job: dj ? dj.name : null, job_status: dj ? dj.status : null,
      reply: `https://store.targo.ca/targo/reply_ticket.php?ticket=${r.id}`,
    }
  })
  return { ok: true, days: d, cutoff_iso: new Date(cutoff * 1000).toISOString(), total: tickets.length, capped: tickets.length >= lim, buckets: bucketCounts, tickets }
}
// Digest → nudge best-effort (Google Chat/n8n via coupon-triage.alertWebhook). Anti-spam : n'alerte que sur les
// tickets NOUVELLEMENT périmés depuis le dernier digest (persistés dans /app/data/stale_nudged.json). Inerte sans webhook.
async function staleNudge () {
  // Par défaut le nudge se limite aux tickets dispatch-pertinents (pool/tech/non-assigné) et exclut le bruit
  // « bureau/asset » (staff hors Dispatch Technician, tickets abandonnés depuis des années). Ajustable par env.
  const nbuckets = (process.env.LEGACY_STALE_BUCKETS || 'pool,tech,unassigned')
  const nmaxAge = Number(process.env.LEGACY_STALE_MAX_AGE_DAYS) || 0
  const r = await staleTickets({ days: Number(process.env.LEGACY_STALE_DAYS) || 3, limit: 1000, buckets: nbuckets, maxAgeDays: nmaxAge }).catch(e => ({ ok: false, error: e.message }))
  if (!r.ok) return r
  const FILE = '/app/data/stale_nudged.json'
  let seen = []; try { seen = JSON.parse(fs.readFileSync(FILE, 'utf8')) } catch (e) {}
  const seenSet = new Set(seen)
  const fresh = r.tickets.filter(t => !seenSet.has(t.ticket))
  try { fs.writeFileSync(FILE, JSON.stringify(r.tickets.map(t => t.ticket))) } catch (e) {} // set courant → un ticket réglé retombe hors liste et pourra ré-alerter s'il redevient périmé
  if (!fresh.length) return { ok: true, total: r.total, fresh: 0, alerted: false }
  const top = fresh.slice(0, 15)
  const lines = top.map(t => (`• #${t.ticket} · ${t.age_days}j · ${t.assignee} · ${t.customer || t.subject}`).slice(0, 120))
  const text = `⏰ ${fresh.length} nouveau(x) ticket(s) sans activité ≥${r.days}j (total ouvert périmé : ${r.total})\n` + lines.join('\n') + (fresh.length > top.length ? `\n… +${fresh.length - top.length} autre(s)` : '')
  let a; try { const ct = require('./coupon-triage'); a = await ct.alertWebhook({ text, kind: 'stale-tickets', total: r.total, fresh: fresh.length }) } catch (e) { a = { alerted: false, reason: e.message } }
  return { ok: true, total: r.total, fresh: fresh.length, alerted: !!(a && a.alerted), webhook: a && a.reason }
}

// ─── APERÇU UNIFIÉ « Publier vers F » (OPS→F) ─────────────────────────────────────────────────────
// Pour chaque Dispatch Job actif portant un legacy_ticket_id (ou un seul si {job}), calcule les changements
// EN ATTENTE à pousser vers F sur 3 axes : (1) assignation tech, (2) fermeture, (3) horaire. LECTURE SEULE
// (0 écriture) — c'est la « liste sommaire des changements » que le bouton « Publier vers F » affiche AVANT.
// L'APPLY réutilise les routes existantes : assignation → push-assignments ; fermeture → batch-close ;
// horaire → requiert une action « schedule » côté ops_reassign.php (F) pas encore présente (signalé par change).
async function publishPreview ({ job = '' } = {}) {
  const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
  const filters = job ? [['name', '=', job]] : [['legacy_ticket_id', '!=', '']]
  const jobs = await erp.list('Dispatch Job', { filters, fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'status', 'scheduled_date', 'start_time', 'subject'], limit: job ? 2 : 3000 })
  if (!jobs.length) return { ok: true, jobs: 0, with_changes: 0, summary: { assign: 0, close: 0, schedule: 0, warnings: 0 }, changes: [] }
  const techIds = [...new Set(jobs.map(j => j.assigned_tech).filter(Boolean))]
  const techs = techIds.length ? 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
  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]))
  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 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 }
  }
  const ids = [...new Set(jobs.map(j => String(j.legacy_ticket_id)).filter(Boolean))]
  const fState = {}
  for (let i = 0; i < ids.length; i += 400) {
    try { const w = await legacyWrite({ action: 'ticket_status', ids: ids.slice(i, i + 400).join(',') }); const rws = (w && w.data && Array.isArray(w.data.rows)) ? w.data.rows : []; for (const rr of rws) fState[String(rr.id)] = rr } catch (e) {}
  }
  const DONE = new Set(['Completed', 'Resolved', 'Closed'])
  const changes = []; const summary = { assign: 0, close: 0, schedule: 0, warnings: 0 }
  for (const j of jobs) {
    if (j.status === 'Cancelled') continue
    const tid = String(j.legacy_ticket_id); const f = fState[tid]
    if (!f) { if (job) changes.push({ job: j.name, ticket: tid, subject: decodeEntities(j.subject || '').slice(0, 60), changes: [], note: 'ticket introuvable dans F (lot pont en échec ?)' }); continue }
    const fOpen = String(f.status || '').toLowerCase() === 'open'
    const ch = []
    if (j.assigned_tech && fOpen) {
      const { staff: st, via } = resolveStaff(techByName[j.assigned_tech], j.assigned_tech)
      if (!st || Number(st.status) !== 1) { ch.push({ field: 'assign_to', kind: 'assign', warn: true, from: String(f.assign_to || ''), to: null, tech: j.assigned_tech, message: '⚠ tech non mappé à un staff F actif (email/nom) — assignation non poussable' }); summary.warnings++ } else if (String(f.assign_to) !== String(st.id)) { ch.push({ field: 'assign_to', kind: 'assign', from: String(f.assign_to || ''), to: String(st.id), tech: j.assigned_tech, staff_name: [st.first_name, st.last_name].filter(Boolean).join(' '), via }); summary.assign++ }
    }
    if (DONE.has(j.status) && fOpen) { ch.push({ field: 'status', kind: 'close', from: 'open', to: 'closed', job_status: j.status }); summary.close++ }
    if (j.scheduled_date && fOpen) {
      const fDue = (f.due_date && Number(f.due_date) > 0) ? new Date(Number(f.due_date) * 1000).toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }) : ''
      if (fDue !== j.scheduled_date) { ch.push({ field: 'due_date', kind: 'schedule', from: fDue || '(aucune)', to: j.scheduled_date, start_time: j.start_time || null, f_side: 'requiert action « schedule » dans ops_reassign.php (non présente)' }); summary.schedule++ }
    }
    if (ch.length) changes.push({ job: j.name, ticket: tid, subject: decodeEntities(j.subject || '').slice(0, 60), assignee: j.assigned_tech || '', changes: ch })
  }
  return { ok: true, jobs: jobs.length, with_changes: changes.length, summary, changes }
}

// APPLY UNIFIÉ « Publier vers F » POUR UN JOB : applique les changements en attente (assign/close/schedule) calculés par
// publishPreview, via ops_reassign.php. = l'action du bouton après confirmation de l'aperçu. dryRun => plan seul (0 écriture).
// only='assign,close,schedule' restreint ; notify=false coupe l'avis courriel au tech. Résultat par changement.
async function publishJob ({ job, dryRun = false, actorEmail = '', notify = true, only = null } = {}) {
  if (!job) return { ok: false, error: 'job requis' }
  const pv = await publishPreview({ job })
  const entry = (pv.changes || []).find(c => c.job === job)
  if (!entry || !entry.changes || !entry.changes.length) return { ok: true, job, ticket: entry ? entry.ticket : null, applied: [], nothing_to_publish: true }
  const ticket = entry.ticket
  const onlySet = only ? new Set(String(only).split(',').map(s => s.trim()).filter(Boolean)) : null
  const actorStaff = dryRun ? 0 : await resolveActorStaff(actorEmail)
  let addr = ''
  try { const dj = await erp.list('Dispatch Job', { filters: [['name', '=', job]], fields: ['address'], limit: 1 }); addr = (dj && dj[0] && dj[0].address) || '' } catch (e) {}
  const applied = []
  for (const c of entry.changes) {
    if (onlySet && !onlySet.has(c.kind)) continue
    if (c.warn) { applied.push({ kind: c.kind, ok: false, skipped: 'warning', message: c.message }); continue }
    let payload = null
    if (c.kind === 'assign') payload = { action: 'reassign', ticket_id: ticket, staff_id: c.to, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: addr }
    else if (c.kind === 'close') payload = { action: 'close', ticket_id: ticket, actor_staff_id: actorStaff || '' }
    else if (c.kind === 'schedule') { const due = Math.floor(Date.parse(c.to + 'T12:00:00-04:00') / 1000); payload = { action: 'schedule', ticket_id: ticket, due_date: due, due_time: c.start_time ? String(c.start_time).slice(0, 5) : '' } }
    if (!payload) continue
    if (dryRun) { applied.push({ kind: c.kind, dryRun: true, payload }); continue }
    const w = await legacyWrite(payload).catch(e => ({ data: { ok: false, error: e.message } }))
    const d = (w && w.data) || {}
    if (d.ok) {
      if (c.kind === 'close') { try { await erp.update('Dispatch Job', job, { status: 'Completed' }) } catch (e) {} }
      audit.record({ job, ticket: String(ticket), event: c.kind === 'assign' ? 'assigned' : (c.kind === 'close' ? 'completed' : 'rescheduled'), to: c.kind === 'assign' ? (c.tech || '') : String(c.to || ''), actor: actorEmail || 'ops', source: 'ops-publish' })
    }
    applied.push({ kind: c.kind, ok: !!d.ok, to: c.to, tech: c.tech, notified: d.notified, affected: d.affected, error: d.error })
  }
  return { ok: applied.every(a => a.ok || a.skipped || a.dryRun), job, ticket, applied }
}

// ═══ HISTORIQUE D'ASSIGNATION PAR TECH (ledger dans la PG ERPNext, à côté de tabDispatch Job/tabIssue) ═══
// Source de vérité = `ticket_msg` CHANGE LOG « assign… change de <from> à <to> » (osTicket ne garde PAS de
// table d'événements ; assign_to = l'assigné COURANT seulement). On reconstruit la timeline complète : chaque
// passage d'un ticket d'un intervenant à un autre, avec date + qui a fait le changement + « venait du pool ».
// UN SEUL chemin backfill+live : scan watermarké de ticket_msg (par id PK). Le 1er run backfille TOUT l'historique ;
// un tick planifié avance le watermark → capte AUTO les nouveaux changements (les nôtres via ops_reassign ET ceux de F).
const ASSIGN_RE = /assign\w*\s+change\s+de\s+(.+?)\s+à\s+([^.<\n]+)/i
function techHistPg () { return require('./address-db').pool() }
const TECH_HIST_WM = '/app/data/tech_history_watermark.json'
async function techHistEnsureSchema () {
  const pg = techHistPg()
  await pg.query(`CREATE TABLE IF NOT EXISTS ops_tech_ticket_history (
    id BIGSERIAL PRIMARY KEY,
    legacy_ticket_id BIGINT NOT NULL,
    changed_at TIMESTAMPTZ NOT NULL,
    to_name TEXT NOT NULL,
    to_staff_id INTEGER,
    tech_id TEXT,
    from_name TEXT,
    from_pool BOOLEAN DEFAULT FALSE,
    changed_by_staff INTEGER,
    dept TEXT,
    source TEXT DEFAULT 'backfill',
    UNIQUE (legacy_ticket_id, changed_at, to_name))`)
  await pg.query('CREATE INDEX IF NOT EXISTS idx_otth_tech ON ops_tech_ticket_history (tech_id)')
  await pg.query('CREATE INDEX IF NOT EXISTS idx_otth_ticket ON ops_tech_ticket_history (legacy_ticket_id)')
}
// index NOM legacy (actifs ET inactifs — l'historique inclut d'ex-employés) → staff.id
async function loadStaffNameIndex () {
  const p = pool(); const [rows] = await p.query("SELECT id, TRIM(CONCAT(COALESCE(first_name,''),' ',COALESCE(last_name,''))) nm FROM staff")
  const byName = new Map(); for (const r of rows) { const k = norm(r.nm); if (k) byName.set(k, r.id) }
  return byName
}
// Scan borné : lit exactement `batch` lignes ticket_msg > watermark (I/O prévisible, indépendant de la densité de matches),
// parse les logs d'assignation, upsert dans la PG. maxBatches plafonne par appel ; reprise via watermark persisté.
async function techHistoryBackfill ({ batch = 5000, maxBatches = 40, throttleMs = 80, reset = false, dryRun = false } = {}) {
  await techHistEnsureSchema()
  const p = pool(); const pg = techHistPg()
  let wm = 0; if (!reset) { try { wm = JSON.parse(fs.readFileSync(TECH_HIST_WM, 'utf8')).id || 0 } catch (e) {} }
  const nameIdx = await loadStaffNameIndex(); const staffToTech = await loadStaffToTech()
  const [depts] = await p.query('SELECT id, name FROM ticket_dept'); const deptName = {}; for (const d of depts) deptName[d.id] = d.name
  let scanned = 0, matched = 0, inserted = 0, batches = 0
  for (; batches < maxBatches; batches++) {
    const [rows] = await p.query('SELECT tm.id, tm.ticket_id, tm.staff_id, tm.date_orig, tm.msg, t.dept_id FROM ticket_msg tm LEFT JOIN ticket t ON t.id = tm.ticket_id WHERE tm.id > ? ORDER BY tm.id ASC LIMIT ?', [wm, batch])
    if (!rows.length) break
    for (const m of rows) {
      wm = m.id; scanned++
      const mm = decodeEntities(m.msg).match(ASSIGN_RE); if (!mm) continue
      const fromName = mm[1].trim()
      let toName = mm[2].split(/\s+(?:date|CHANGE\s*LOG|statut|status|sujet|priorit)\b/i)[0].trim().slice(0, 120)
      if (!toName) continue
      matched++
      const toStaff = nameIdx.get(norm(toName)) || null
      const techId = toStaff ? (staffToTech[toStaff] || ('TECH-' + toStaff)) : null
      if (dryRun) continue
      const r = await pg.query(
        `INSERT INTO ops_tech_ticket_history (legacy_ticket_id, changed_at, to_name, to_staff_id, tech_id, from_name, from_pool, changed_by_staff, dept, source)
         VALUES ($1, to_timestamp($2), $3, $4, $5, $6, $7, $8, $9, 'backfill') ON CONFLICT (legacy_ticket_id, changed_at, to_name) DO NOTHING`,
        [m.ticket_id, m.date_orig, toName, toStaff, techId, fromName, /tech targo/i.test(fromName), m.staff_id, deptName[m.dept_id] || null])
      inserted += r.rowCount || 0
    }
    if (!dryRun) { try { fs.writeFileSync(TECH_HIST_WM, JSON.stringify({ id: wm, at: new Date().toISOString() })) } catch (e) {} }
    if (rows.length < batch) { batches++; break } // fin atteinte
    if (throttleMs) await new Promise(r => setTimeout(r, throttleMs))
  }
  return { ok: true, dryRun, scanned, matched, inserted, batches, watermark: wm, done: batches < maxBatches }
}
// Rapport — résolution DYNAMIQUE vers l'IDENTITÉ canonique (lib/identity : SOURCE UNIQUE, éditable via l'onglet
// « Identités »). On ne stocke QUE les faits bruts (to_name/to_staff_id) ; le regroupement par personne est calculé
// À LA LECTURE via resolveIdentity(staff_id → nom) → corriger une identité (Settings) reclasse le ledger AUSSITÔT.
// is_tech = l'identité a un tech_id (⇒ Mégane, staff admin sans tech_id, n'est PAS comptée comme tech). Les noms
// non résolus (ex. prénom seul « Mégane ») remontent dans `unresolved` pour être mappés dynamiquement.
async function techHistory ({ tech = '', limit = 200 } = {}) {
  await techHistEnsureSchema()
  const pg = techHistPg(); const idn = require('./identity'); const lim = Math.max(1, Math.min(5000, Number(limit) || 200))
  const resolve = (staffId, name) => (staffId != null && idn.resolveIdentity(String(staffId))) || idn.resolveIdentity(name || '') || null
  if (tech) { // tech = clé identité | courriel | tech_id | staff_id | nom
    const id = idn.resolveIdentity(tech)
    const params = []; const conds = []
    if (id && id.legacy_staff_id) { params.push(Number(id.legacy_staff_id)); conds.push('to_staff_id = $' + params.length) }
    const names = [...new Set([id && id.label, tech].filter(Boolean))]
    if (names.length) { params.push(names); conds.push('to_name = ANY($' + params.length + ')') }
    if (!conds.length) return { ok: true, query: tech, identity: null, total: 0, from_pool: 0, rows: [] }
    const where = '(' + conds.join(' OR ') + ')'
    params.push(lim)
    const r = await pg.query('SELECT legacy_ticket_id, changed_at, to_name, to_staff_id, from_name, from_pool, dept FROM ops_tech_ticket_history WHERE ' + where + ' ORDER BY changed_at DESC LIMIT $' + params.length, params)
    const tot = await pg.query('SELECT COUNT(*)::int n, SUM(CASE WHEN from_pool THEN 1 ELSE 0 END)::int pool FROM ops_tech_ticket_history WHERE ' + where, params.slice(0, params.length - 1))
    return { ok: true, query: tech, identity: id ? { key: id.key, label: id.label, email: id.primary_email, tech_id: id.tech_id || '', is_tech: !!id.tech_id, kind: id.kind, active: id.active !== false } : null, total: tot.rows[0].n, from_pool: tot.rows[0].pool, rows: r.rows }
  }
  // Récap : on agrège les faits bruts (borné : ~260 couples), puis on résout chacun vers l'identité.
  const g = await pg.query('SELECT to_staff_id, to_name, COUNT(*)::int total, SUM(CASE WHEN from_pool THEN 1 ELSE 0 END)::int from_pool, MAX(changed_at) last_at FROM ops_tech_ticket_history GROUP BY to_staff_id, to_name')
  const byKey = new Map()
  for (const row of g.rows) {
    const id = resolve(row.to_staff_id, row.to_name)
    const key = id ? id.key : ('name:' + row.to_name)
    let e = byKey.get(key)
    if (!e) { e = { key, label: id ? id.label : row.to_name, email: id ? id.primary_email : '', is_tech: id ? !!id.tech_id : false, kind: id ? id.kind : null, resolved: !!id, total: 0, from_pool: 0, last_at: null, raw_names: [] }; byKey.set(key, e) }
    e.total += row.total; e.from_pool += row.from_pool
    if (!e.last_at || row.last_at > e.last_at) e.last_at = row.last_at
    if (!id) e.raw_names.push({ to_name: row.to_name, to_staff_id: row.to_staff_id })
  }
  const all = [...byKey.values()].sort((a, b) => b.total - a.total)
  const grand = await pg.query('SELECT COUNT(*)::int n FROM ops_tech_ticket_history')
  return {
    ok: true, total_rows: grand.rows[0].n, assignees: all.length,
    techs: all.filter(x => x.is_tech).length, non_tech: all.filter(x => x.resolved && !x.is_tech).length,
    unresolved: all.filter(x => !x.resolved).map(x => ({ label: x.label, total: x.total, staff_id: (x.raw_names[0] || {}).to_staff_id || null })).slice(0, 50),
    per_assignee: all.slice(0, lim).map(({ raw_names, ...e }) => e),
  }
}

// PROVISIONING IDENTITÉS depuis le legacy staff → fait CONVERGER le ledger. Pour chaque staff apparaissant dans les
// assignations : garantit une identité canonique portant son legacy_staff_id (+ tech_id SI Dispatch Technician → les
// admins restent SANS tech_id). LINK (identité trouvée par courriel/nom → renseigne legacy_staff_id) ou CREATE (aucune →
// crée depuis le staff). Idempotent. dryRun => 0 écriture. scope 'ledger' (défaut : staff du ledger) | 'active' (tout staff actif).
// Comptes SYSTÈME du legacy staff (pas des personnes) → jamais d'identité auto.
const SYS_STAFF_RE = /ticket\s*dispatch|gestion\s*inventaire|^tech\s*targo$|^test\b/i
// Index nom→emails depuis les sources EXTERNES (ERPNext Employee + Authentik) — enrichit les staff sans courriel
// legacy (ex-employés hors staff.email) ET alimente les alias multi-courriels (cf 3 sources de noms/identité).
async function extEmailIndex () {
  const byName = new Map() // norm(nom complet) → Set(emails)
  const add = (name, mail) => { const k = norm(name || ''); const v = String(mail || '').trim().toLowerCase(); if (!k || !v || !v.includes('@')) return; const set = byName.get(k) || new Set(); set.add(v); byName.set(k, set) }
  try {
    const emps = await erp.list('Employee', { fields: ['employee_name', 'company_email', 'prefered_email', 'personal_email', 'user_id'], limit: 2000 })
    for (const e of (emps || [])) for (const m of [e.company_email, e.prefered_email, e.personal_email, e.user_id]) add(e.employee_name, m)
  } catch (e) {}
  try {
    const d = await akGet('/core/users/?page_size=500')
    for (const u of ((d && d.results) || [])) add(u.name, u.email)
  } catch (e) {}
  return byName
}
async function provisionIdentities ({ dryRun = true, scope = 'ledger' } = {}) {
  const p = pool(); const pg = techHistPg(); const idn = require('./identity')
  let staffIds = []
  if (scope === 'active') { const [r] = await p.query('SELECT id FROM staff WHERE status=1'); staffIds = r.map(x => x.id) } else { const r = await pg.query('SELECT DISTINCT to_staff_id FROM ops_tech_ticket_history WHERE to_staff_id IS NOT NULL'); staffIds = r.rows.map(x => x.to_staff_id) }
  if (!staffIds.length) return { ok: true, scope, targets: 0, linked: 0, created: 0, enriched: 0, already: 0, system: 0, no_email: [], samples: [] }
  const [staff] = await p.query('SELECT id, first_name, last_name, email, status FROM staff WHERE id IN (?)', [staffIds])
  const byId = new Map(staff.map(s => [String(s.id), s]))
  const techs = await erp.list('Dispatch Technician', { fields: ['name', 'technician_id'], limit: 1000 })
  const techByStaff = {}; for (const t of (techs || [])) { const m = String(t.technician_id || '').match(/(\d+)$/); if (m) techByStaff[m[1]] = t.technician_id || t.name }
  const ext = await extEmailIndex()
  const res = { ok: true, scope, dryRun, targets: staffIds.length, linked: 0, created: 0, enriched: 0, already: 0, system: 0, no_email: [], samples: [] }
  for (const sid of staffIds) {
    const s = byId.get(String(sid)); if (!s) continue
    const full = [s.first_name, s.last_name].filter(Boolean).join(' ').trim(); const techId = techByStaff[String(sid)] || ''
    if (SYS_STAFF_RE.test(full)) { res.system++; continue } // compte système → pas d'identité personne
    // TOUS les courriels connus (legacy → primaire préféré, puis Employee/Authentik) : le 1er = primaire, le reste = alias.
    const legacyMail = norm(s.email)
    const allEmails = [...new Set([legacyMail, ...(ext.get(norm(full)) || [])].filter(Boolean))]
    const existing = idn.resolveIdentity(String(sid))
    if (existing && String(existing.legacy_staff_id) === String(sid)) {
      // Déjà lié : enrichit quand même (tech_id manquant / NOUVEAUX courriels → alias, union jamais overwrite).
      const have = new Set([existing.primary_email, ...(existing.alias_emails || [])].map(x => String(x || '').toLowerCase()))
      const newMails = allEmails.filter(e => !have.has(e))
      const needTech = techId && !existing.tech_id
      if (needTech || newMails.length) {
        if (!dryRun) idn.upsert({ key: existing.key, label: existing.label, primary_email: existing.primary_email, legacy_staff_id: sid, tech_id: techId || existing.tech_id, alias_emails: newMails })
        res.enriched++; if (res.samples.length < 40) res.samples.push({ staff: sid, action: 'enrich', key: existing.key, add_aliases: newMails, tech: needTech ? techId : undefined })
      } else res.already++
      continue
    }
    // Identité existante par un des courriels ou par nom → LINK (renseigne legacy_staff_id + alias découverts).
    let cand = null
    for (const e of allEmails) { cand = idn.resolveIdentity(e); if (cand) break }
    if (!cand && full) cand = idn.resolveIdentity(full)
    if (cand) { if (!dryRun) idn.upsert({ key: cand.key, label: cand.label, primary_email: cand.primary_email, alias_emails: allEmails, legacy_staff_id: sid, tech_id: techId || cand.tech_id }); res.linked++; if (res.samples.length < 40) res.samples.push({ staff: sid, name: full, action: 'link', key: cand.key, tech: !!techId }); continue }
    // Aucune identité → CREATE avec le 1er courriel connu en primaire + les autres en alias.
    if (allEmails.length) { if (!dryRun) idn.upsert({ label: full || allEmails[0], primary_email: allEmails[0], alias_emails: allEmails.slice(1), legacy_staff_id: sid, tech_id: techId, kind: 'employee', active: Number(s.status) === 1 }); res.created++; if (res.samples.length < 40) res.samples.push({ staff: sid, name: full, action: 'create', primary: allEmails[0], aliases: allEmails.slice(1), tech: !!techId }); continue }
    res.no_email.push({ staff: sid, name: full })
  }
  return res
}

module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, postTicketLegacy, ticketThread, ticketAssignState, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, backfillServiceLocations, backfillIssueCustomers, backfillSourceIssue, backfillDependsOn, geolocateJobs, fibreByDelivery, startSync, stopSync, fetchTargoTickets, staleTickets, staleNudge, publishPreview, publishJob, techHistoryBackfill, techHistory, provisionIdentities, coord, prio, startTime, jobType, inTerritory, geocodeRQA, persistGeocodeToSL, reverseNearestFibre } // parseurs purs exposés pour les tests
