gigafibre-fsm/services/targo-hub/lib/legacy-dispatch-sync.js
louispaulb 0f65c02d83 feat(fsm): platform build — comms UI, F→ERPNext sync/billing, roster, campaigns, network, reports
Accumulated work on the dispatch/legacy-writeback branch:
- Communications UI: CommunicationsPage, ConversationFullPage, DepartmentBoard,
  PipelineBoard, ReaderStack, Orchestrator/NewTicket/ServiceStatus/Outbox dialogs;
  hub gmail.js, ticket-collab.js, outbox.js, coupon-triage.js, client-diag.js.
- Billing/sync mirror (F→ERPNext): legacy-payments.js, legacy-sync.js,
  sync-orchestrator.js, supplier-invoices.js, municipality.js + incremental
  migration scripts; LegacySyncPage, SupplierInvoices + negative-billing /
  terminated-active reports.
- Roster/campaigns/network/voice: roster + roster-assistant, campaigns, giftbit,
  olt-snmp, traccar, twilio, vision, tech-absence-sms, ai/agent/config/helpers,
  legacy-dispatch-sync; ops PlanificationPage, RapportsPage, Settings, Tickets,
  ClientDetail updates.
- docs/ PLATFORM_GUIDE + UI_AND_OPTIMIZATION; .gitignore __pycache__.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 06:12:12 -04:00

1174 lines
92 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use strict'
/**
* legacy-dispatch-sync.js — PONT legacy (osTicket/MariaDB) → Dispatch Job (ERPNext).
*
* Tire RÉGULIÈREMENT les tickets ouverts assignés au compte « Tech Targo »
* (staff id 3301 dans la DB legacy `gestionclient`) et crée/maj un Dispatch Job
* dans ERPNext pour les répartir sur la grille Planification / le tableau Dispatch.
*
* Pourquoi 3301 : dans le legacy, le travail terrain à dispatcher est assigné au
* compte générique « Tech Targo » (default_staff des dépts Installation/Réparation/
* Fibre). C'est exactement « les tickets assignés à tech targo ».
*
* IDEMPOTENT : chaque ticket legacy porte un `legacy_ticket_id` sur le Dispatch Job.
* On cherche avant de créer → jamais de doublon. On NE clobbe PAS le travail du
* répartiteur : un job déjà assigné/déplacé n'est plus touché (maj de date seulement
* tant qu'il est encore `open` + non assigné).
*
* Routes : GET /dispatch/legacy-sync/preview (dry-run, 0 écriture) · POST /dispatch/legacy-sync/run
* Récurrence : startSync() (setInterval, cf. server.js), désactivable via LEGACY_DISPATCH_SYNC=off.
*
* Pré-requis : champ Custom Field `legacy_ticket_id` sur Dispatch Job
* (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py).
*/
const erp = require('./erp')
const cfg = require('./config')
const { log, json, httpRequest } = require('./helpers')
const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo
const addrdb = require('./address-db') // pool PG local (camping_registry)
const sse = require('./sse') // diffusion temps-réel vers Ops (topic 'dispatch')
const fs = require('fs')
// Pont d'ÉCRITURE legacy = endpoint PHP sur facturation.targo.ca (hérite des droits write de l'app ; aucun grant DB).
// Token lu d'un FICHIER (/app/data/ops_legacy.token) → pas de var d'env → pas besoin de recréer le conteneur.
const OPS_LEGACY_URL = process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php'
function opsLegacyToken () { try { return (process.env.OPS_LEGACY_TOKEN || fs.readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch (e) { return (process.env.OPS_LEGACY_TOKEN || '').trim() } }
async function legacyWrite (fields) { // POST form-urlencoded + X-Ops-Token → ops_reassign.php
const body = Object.entries(fields).map(([k, v]) => encodeURIComponent(k) + '=' + encodeURIComponent(v == null ? '' : v)).join('&')
return httpRequest(OPS_LEGACY_URL, '', { method: 'POST', body, headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'X-Ops-Token': opsLegacyToken() }, timeout: 15000 })
}
// Ferme un ticket dans le legacy (via PHP : status=closed + date_closed + closed_by=acteur + réouverture enfants + log).
async function closeTicketLegacy (ticketId, actorEmail) {
let actorStaff = 0
try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } } catch (e) {}
const w = await legacyWrite({ action: 'close', ticket_id: ticketId, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
return (w && w.data) || { ok: false, error: 'no response' }
}
// Fermeture EN LOT : 1 seul appel PHP (1 connexion) pour N tickets → bien plus rapide que N appels.
async function batchCloseLegacy (ticketIds, actorEmail) {
let actorStaff = 0
try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } } catch (e) {}
const w = await legacyWrite({ action: 'batch_close', ticket_ids: ticketIds.join(','), actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
return (w && w.data) || { ok: false, error: 'no response' }
}
// Résout le staff legacy de l'acteur Ops depuis son email Authentik (0 si introuvable).
async function resolveActorStaff (actorEmail) {
try { const p = pool(); if (p && actorEmail) { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) return ar[0].id } } catch (e) {}
return 0
}
// RETOUR AU POOL : désassigne le Dispatch Job dans ERPNext PUIS — si le job était poussé à un tech —
// réécrit le ticket legacy à assign_to=3301 (Tech Targo, l'inbox de dispatch). Action EXPLICITE seulement
// (les redistributions auto vers un autre tech poussent le nouveau tech au prochain Publier, pas 3301).
async function returnToPool (job, actorEmail) {
if (!job) return { ok: false, error: 'job requis' }
const djs = await erp.list('Dispatch Job', { filters: [['name', '=', job]], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'status'], limit: 1 })
const j = djs && djs[0]
if (!j) return { ok: false, error: 'job introuvable' }
const hadTech = !!j.assigned_tech
const r = await erp.update('Dispatch Job', job, { assigned_tech: null, status: 'open', start_time: null }).catch(e => ({ ok: false, error: e.message }))
let legacy = null
if (hadTech && j.legacy_ticket_id) { // n'écrit au legacy que si le ticket avait réellement été attribué
const actorStaff = await resolveActorStaff(actorEmail)
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: TARGO_TECH_STAFF_ID, actor_staff_id: actorStaff || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
legacy = (w && w.data) || { ok: false, error: 'no response' }
}
return { ok: !(r && r.ok === false), job, erp: r, legacy, returned_to_pool: !!(legacy && legacy.ok) }
}
// Campings : l'adresse de service est un terrain de camping (≠ résidence du client). On force la géoloc
// FIXE du camping (registre camping_registry). Détection robuste : le texte doit contenir « camping » OU
// un mot-clé de LIEU spécifique (évite les faux positifs de patronyme, ex. « Daniel Dauphinais »).
const CAMP_PLACE_KW = ['lac des pins', 'lac de pins', 'sandysun', 'sandy sun', 'frontiere', 'ensoleill']
let _campings = null; let _campingsAt = 0
async function getCampings () {
if (_campings && (Date.now() - _campingsAt) < 600000) return _campings // cache 10 min
try { const r = await addrdb.pool().query('SELECT keyword, name, latitude, longitude FROM camping_registry WHERE active'); _campings = r.rows; _campingsAt = Date.now() } catch (e) { _campings = _campings || [] }
return _campings
}
// fields en ORDRE DE PRIORITÉ (sujet d'abord = label de service explicite, puis ville/adresse de delivery).
// Le 1er champ qui contient un signal camping décide → évite qu'une ville de delivery (résidence) écrase le sujet.
function campingFor (campings, fields) {
for (const f of (Array.isArray(fields) ? fields : [fields])) {
const t = norm(f || '')
if (!(t.includes('camping') || CAMP_PLACE_KW.some(k => t.includes(k)))) continue
for (const c of campings) if (c.keyword && t.includes(c.keyword)) return c
}
return null
}
let mysql
try { mysql = require('mysql2/promise') } catch { /* dépendance optionnelle */ }
const TARGO_TECH_STAFF_ID = Number(process.env.LEGACY_TARGO_STAFF_ID) || 3301 // compte « Tech Targo » (pool de dispatch)
// Parseurs/mapping PURS extraits dans util/legacy-parse (testables en isolation) :
// DEPT_JOBTYPE/DUR + jobType/prio/tzDate/startTime/coord. + norm (util/text). (Phase 1 : logique pure séparée des I/O.)
const { DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord } = require('./util/legacy-parse')
const { norm } = require('./util/text')
// Géocodage de repli via RQA (Répertoire des adresses du Québec) — source autoritaire, fiable en
// rural (vs Mapbox qui peut dévier de plusieurs km). Cache au niveau MODULE (persiste entre les ticks)
// → chaque adresse n'est géocodée qu'une fois par cycle de vie du hub ; les échecs sont mémorisés
// (valeur null) pour ne PAS marteler RQA à chaque cycle. N'accepte qu'une correspondance fiable (≥0.7).
// Géocodage RQA via la RECHERCHE TRIGRAM (RPC `search_addresses`, pg_trgm) — celle de l'autocomplete de
// dispo. Trouve les rues que l'ilike manquait (générique géré par la colonne `long` + trigram phase 2).
// GARDE-FOU de zone : le civique doit concorder ET le CP OU la ville doit confirmer la région → rejette
// les faux positifs trigram hors-territoire (ex. « Rue Grenet, Montréal » quand un civique René-Vinet
// absent du RQA déclenche la phase 2). Cache module (1 appel/adresse/vie ; échecs mémorisés).
// TERRITOIRE de service TARGO (Montérégie sud-ouest : Haut-St-Laurent, Beauharnois-Salaberry, Jardins-de-Napierville,
// Roussillon). Rejette les géocodes hors-zone — Mapbox/trigram tombent sinon sur des HOMONYMES (Gaspésie, Outaouais,
// Estrie : « Saint-Louis », « Franklin », « Lac des Pins »…). bbox volontairement large mais bien en deçà du reste du QC.
const TERR = { latMin: 44.85, latMax: 45.6, lonMin: -74.85, lonMax: -73.2 }
function inTerritory (lat, lon) { const a = parseFloat(lat), o = parseFloat(lon); return isFinite(a) && isFinite(o) && a >= TERR.latMin && a <= TERR.latMax && o >= TERR.lonMin && o <= TERR.lonMax }
// Repli CENTROÏDE (code postal complet, sinon ville) depuis rqa_addresses LOCAL — « au pire, le centre du CP/ville ».
// Toujours EN territoire (filtré par bbox). Cache module.
const _centroidCache = new Map()
async function geocodeCentroid (postalCode, city) {
const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase()
const key = 'ctr|' + fsa + '|' + norm(city)
if (_centroidCache.has(key)) return _centroidCache.get(key)
let res = null
try {
const pg = addrdb.pool()
if (fsa.length >= 6) { // centroïde du code postal complet (zone fine, ex. J0S1T0)
const r = await pg.query("SELECT avg(latitude) AS lat, avg(longitude) AS lon, count(*) AS n FROM rqa_addresses WHERE replace(upper(code_postal), ' ', '') = $1", [fsa])
const row = r.rows[0]
if (row && Number(row.n) > 0 && inTerritory(row.lat, row.lon)) res = { lat: +row.lat, lon: +row.lon }
}
if (!res && city) { // sinon centroïde de la VILLE, contraint au territoire
const r = await pg.query("SELECT avg(latitude) AS lat, avg(longitude) AS lon, count(*) AS n FROM rqa_addresses WHERE lower(unaccent(ville)) = lower(unaccent($1)) AND latitude BETWEEN $2 AND $3 AND longitude BETWEEN $4 AND $5", [city, TERR.latMin, TERR.latMax, TERR.lonMin, TERR.lonMax])
const row = r.rows[0]
if (row && Number(row.n) > 0) res = { lat: +row.lat, lon: +row.lon }
}
} catch (e) { log('geocodeCentroid error:', e.message) }
_centroidCache.set(key, res)
return res
}
const _geoCache = new Map()
async function geocodeRQA (addressLine, postalCode, city) {
const key = norm([addressLine, postalCode, city].filter(Boolean).join('|'))
if (!key || !addressLine) return null
if (_geoCache.has(key)) return _geoCache.get(key)
let res = null
try {
const rows = await searchAddressesRpc(addressLine, 8)
if (rows && rows.length) {
const civic = (String(addressLine).match(/^\s*(\d+)/) || [])[1] || null
const fsa = String(postalCode || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
const cityN = norm(city)
const GEN = ['rue', 'rang', 'chemin', 'ch', 'route', 'rte', 'avenue', 'av', 'ave', 'boul', 'boulevard', 'bd', 'montee', 'cote', 'place', 'pl', 'allee', 'terrasse', 'croissant', 'des', 'de', 'du', 'la', 'le', 'aux']
const streetToks = norm(addressLine).replace(/^\s*\d+\s*/, '').split(/[\s-]+/).filter(w => w.length >= 3 && !GEN.includes(w)) // tokens significatifs du nom de rue
const streetOk = (r) => { if (!streetToks.length) return true; const hay = norm((r.odonyme_recompose_normal || '') + ' ' + (r.adresse_formatee || '')); return streetToks.some(w => hay.includes(w)) }
const pick = rows.find(r => {
if (!coord(r.latitude, r.longitude) || !inTerritory(r.latitude, r.longitude)) return false // hors territoire = homonyme → rejet
if (civic && String(r.numero_municipal || '') !== civic) return false // mauvais numéro civique → rejet
if (!streetOk(r)) return false // bon civique mais mauvaise rue (faux positif trigram) → rejet
const rFsa = String(r.code_postal || '').replace(/\s+/g, '').toUpperCase().slice(0, 3)
// En TERRITOIRE Targo (J0L/J0S, déjà priorisé par la RPC + filtrage mots-de-rue en phase 1) → on fait
// confiance au classement RPC (= l'autocomplete client). Civique + rue concordent déjà.
if (rFsa === 'J0L' || rFsa === 'J0S') return true
// Hors territoire → exiger une concordance EXPLICITE avec l'enregistrement legacy (CP OU ville),
// sinon rejet (ex. faux positif trigram « Rue Grenet, Montréal H4L »).
const rCity = norm(r.nom_municipalite)
const postalOk = !!(fsa && rFsa && rFsa === fsa)
const cityOk = !!(cityN && rCity && (rCity.includes(cityN) || cityN.includes(rCity) || rCity.split('-')[0] === cityN.split('-')[0]))
return postalOk || cityOk
})
if (pick) res = coord(pick.latitude, pick.longitude)
}
} catch (e) { log('geocodeRQA error:', e.message) } // RQA indispo → pas de coords (échec mémorisé)
_geoCache.set(key, res)
return res
}
// Repli Mapbox (token public déjà utilisé par le Dispatch) pour les rues TROP RÉCENTES pour le RQA
// (nouveaux développements absents du répertoire). Moins précis en rural que le RQA mais « une coord
// vaut mieux que zéro » pour le routage. Contraint au Québec (country=ca + proximity Montérégie +
// bornes coord()). Cache module. Désactivé si MAPBOX_TOKEN absent de l'env.
const MAPBOX_TOKEN = process.env.MAPBOX_TOKEN || ''
const _mbCache = new Map()
async function geocodeMapbox (addressLine, city, postalCode) {
if (!MAPBOX_TOKEN || !addressLine) return null
const key = norm([addressLine, city, postalCode].filter(Boolean).join('|'))
if (_mbCache.has(key)) return _mbCache.get(key)
let res = null
try {
const q = [addressLine, city, 'Québec'].filter(Boolean).join(', ')
const url = `https://api.mapbox.com/geocoding/v5/mapbox.places/${encodeURIComponent(q)}.json` +
`?country=ca&proximity=-73.5,45.2&limit=1&types=address&language=fr&access_token=${MAPBOX_TOKEN}`
const r = await httpRequest(url, '', { timeout: 12000 })
const f = r && r.data && Array.isArray(r.data.features) && r.data.features[0]
if (f && Array.isArray(f.center) && (f.relevance == null || f.relevance >= 0.6)) {
const c = coord(f.center[1], f.center[0]) // Mapbox = [lon, lat]
if (c && inTerritory(c.lat, c.lon)) res = c // hors territoire (proximité insuffisante → homonyme lointain) → rejet
}
} catch (e) { log('geocodeMapbox error:', e.message) }
_mbCache.set(key, res)
return res
}
let _pool
function pool () {
if (!mysql) return null
if (!_pool) {
_pool = mysql.createPool({
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME,
connectionLimit: 2, waitForConnections: true, connectTimeout: 8000,
})
}
return _pool
}
// Lien d'activation STB/Ministra : DÉJÀ posté dans le fil du ticket par le wizard legacy à la vente.
// On le ré-extrait tel quel (zéro reconstruction). Sous-requête = le ticket_msg le plus récent qui le contient.
const ACTIVATION_RE = /https?:\/\/[^\s"'<>]*connect_ministra\.php[^\s"'<>]*/i
function extractActivationUrl (msg) { if (!msg) return ''; const m = String(msg).match(ACTIVATION_RE); return m ? m[0] : '' }
// Détail du ticket = 1er message du fil legacy (HTML osTicket) → texte lisible, tronqué, pour l'afficher dans Ops.
function stripHtml (html, max = 1500) {
if (!html) return ''
let s = String(html)
.replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\/\s*(p|div|li|tr)\s*>/gi, '\n')
.replace(/<[^>]+>/g, '')
.replace(/&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')
const [rows] = await p.query(
`SELECT t.id, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
t.date_create, t.last_update,
a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip,
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
(SELECT mm.msg FROM ticket_msg mm
WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%'
ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
(SELECT mm3.msg FROM ticket_msg mm3
WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
FROM ticket t
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
LEFT JOIN account a ON a.id = t.account_id
LEFT JOIN delivery dv ON dv.id = COALESCE(
NULLIF(t.delivery_id, 0),
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
)
WHERE t.status = 'open' AND t.assign_to = ?
ORDER BY t.due_date DESC`,
[TARGO_TECH_STAFF_ID],
)
return rows || []
}
// caches par run (vidés à chaque cycle) pour éviter les requêtes répétées
let _custCache = new Map()
let _slCache = new Map()
function resetCaches () { _custCache = new Map(); _slCache = new Map() }
async function resolveCustomer (accountId) {
if (!accountId) return null
const k = String(accountId)
if (_custCache.has(k)) return _custCache.get(k)
const r = await erp.list('Customer', { filters: [['legacy_account_id', '=', k]], fields: ['name', 'customer_name'], limit: 1 })
const c = (r && r[0]) || null
_custCache.set(k, c)
return c
}
async function resolveServiceLocation (custName, city) {
if (!custName) return null
let list = _slCache.get(custName)
if (!list) {
list = (await erp.list('Service Location', { filters: [['customer', '=', custName]], fields: ['name', 'address_line', 'city', 'latitude', 'longitude'], limit: 10 })) || []
_slCache.set(custName, list)
}
if (!list.length) return null
if (city) { const hit = list.find(l => norm(l.city) === norm(city)); if (hit) return hit } // préfère la ville qui matche
return list[0]
}
// Construit le payload Dispatch Job à partir d'un ticket legacy (+ infos de matching).
async function buildJob (t) {
const cust = await resolveCustomer(t.account_id)
const sl = cust ? await resolveServiceLocation(cust.name, t.city) : null
const jt = jobType(t.dept_id)
const cname = cust ? cust.customer_name : ([t.first_name, t.last_name].filter(Boolean).join(' ') || t.company || '')
// Coords : la table legacy `delivery` (point de service réel, via ticket.delivery_id) est la
// source la plus fiable (lat/long par adresse). On préfère donc l'adresse de service à l'adresse
// de facturation du compte, et les coords delivery aux coords Service Location ERPNext (placeholders).
const dc = coord(t.dv_lat, t.dv_lon)
const svcAddr = [t.dv_addr, t.dv_city, t.dv_zip].filter(Boolean).join(', ')
const billAddr = [t.address1, t.address2, t.city, t.state, t.zip].filter(Boolean).join(', ')
const addr = svcAddr || billAddr
let subject = (t.subject || '').trim() || ([t.dept, cname].filter(Boolean).join(' — '))
const idTag = ' · #' + t.id // n° de ticket legacy visible dans le TITRE (référence croisée osTicket) — réserve la place pour ne pas le tronquer
subject = (subject.length + idTag.length > 140 ? subject.slice(0, 140 - idTag.length) : subject) + idTag
const payload = {
ticket_id: 'LEG-' + t.id,
subject,
job_type: jt,
duration_h: DUR[jt] || 1,
priority: prio(t.priority),
status: 'open',
order_source: 'Manual',
legacy_ticket_id: String(t.id),
legacy_dept: t.dept || '', // département legacy granulaire → coloriage « comme legacy » (Installation Fibre / Réparation Fibre / Télé / Téléphonie…)
}
if (addr) payload.address = addr.slice(0, 140) // adresse de SERVICE (delivery, sinon facturation) EN CLAIR — toujours importée, indépendamment du lien Service Location / des coords (champ Frappe Data = 140 car.)
const actUrl = extractActivationUrl(t.activation_msg); if (actUrl) payload.legacy_activation_url = actUrl // lien connect_ministra (déjà dans le fil)
// En-tête de dates (ouverture + dernière MàJ) pour juger l'ancienneté → décider de fermer ; puis la description.
const dateHdr = '🎫 Ticket #' + t.id + ' · 🗓 Ouvert ' + (tzDate(t.date_create) || '?') + (t.last_update ? ' · MàJ ' + tzDate(t.last_update) : '')
const detail = [dateHdr, stripHtml(t.first_msg)].filter(Boolean).join('\n\n')
if (detail) payload.legacy_detail = detail // description + dates → visible dans Ops (mouseover panneau + détail Dispatch)
const sd = tzDate(t.due_date); if (sd) payload.scheduled_date = sd
const st = startTime(t.due_time); if (st) payload.start_time = st
if (cust) payload.customer = cust.name
let coordSrc = null
// CAMPING (priorité max) : l'adresse de service est un terrain de camping → géoloc FIXE du camping,
// pas la résidence du client. Signal = sujet/ville/adresse de service du ticket.
const camp = campingFor(await getCampings(), [t.subject, t.dv_city, t.dv_addr])
if (camp) { payload.latitude = camp.latitude; payload.longitude = camp.longitude; coordSrc = 'camping' }
if (!coordSrc && dc) { payload.latitude = dc.lat; payload.longitude = dc.lon; coordSrc = 'delivery' } // point de service legacy
if (sl) {
payload.service_location = sl.name
if (!coordSrc) { const sc = coord(sl.latitude, sl.longitude); if (sc) { payload.latitude = sc.lat; payload.longitude = sc.lon; coordSrc = 'service_location' } } // repli si pas de delivery
}
if (!coordSrc && addr) { // replis géocodage sur l'adresse de service (sinon facturation) : RQA (autoritaire) puis Mapbox (couverture)
const useSvc = !!svcAddr
const line = useSvc ? t.dv_addr : t.address1
const zip = useSvc ? t.dv_zip : t.zip
const ci = useSvc ? t.dv_city : t.city
const g = await geocodeRQA(line, zip, ci)
if (g) { payload.latitude = g.lat; payload.longitude = g.lon; coordSrc = 'rqa_geocode' }
else { const mb = await geocodeMapbox(line, ci, zip); if (mb) { payload.latitude = mb.lat; payload.longitude = mb.lon; coordSrc = 'mapbox_geocode' } }
}
return { legacy_id: String(t.id), payload, matched: { customer: !!cust, service_location: !!sl, customer_name: cname, coords: !!coordSrc, coord_src: coordSrc, delivery_id: t.delivery_id || null }, dept: t.dept, addr }
}
async function findExisting (legacyId) {
const r = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', legacyId]], fields: ['name', 'status', 'assigned_tech', 'scheduled_date', 'subject', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location', 'address'], limit: 1 })
return (r && r[0]) || null
}
// VERROU de sérialisation : frappe_pg ne supporte pas la concurrence. Le tick récurrent ET les runs
// manuels (preview/run) passent tous par `sync()` → on les met en FILE pour qu'ils ne se chevauchent
// JAMAIS (sinon « socket hang up » + écritures perdues dans un rollback). Chaque appel attend le précédent.
let _syncLock = Promise.resolve()
function sync (opts = {}) {
const run = _syncLock.then(() => syncImpl(opts), () => syncImpl(opts))
_syncLock = run.then(() => {}, () => {}) // le suivant attend, quel que soit le résultat
return run
}
// 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 } = {}) {
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
const [rows] = await p.query(
`SELECT t.id, t.assign_to, t.subject, t.dept_id, dd.name AS dept, t.due_date, t.due_time, t.priority, t.bon_id, t.account_id, t.delivery_id,
t.date_create, t.last_update,
a.first_name, a.last_name, a.company, a.address1, a.address2, a.city, a.state, a.zip,
dv.latitude AS dv_lat, dv.longitude AS dv_lon, dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip,
(SELECT mm.msg FROM ticket_msg mm WHERE mm.ticket_id = t.id AND mm.msg LIKE '%connect_ministra%' ORDER BY mm.id DESC LIMIT 1) AS activation_msg,
(SELECT mm3.msg FROM ticket_msg mm3 WHERE mm3.ticket_id = t.id ORDER BY mm3.id ASC LIMIT 1) AS first_msg
FROM ticket t
LEFT JOIN ticket_dept dd ON dd.id = t.dept_id
LEFT JOIN account a ON a.id = t.account_id
LEFT JOIN delivery dv ON dv.id = COALESCE(NULLIF(t.delivery_id, 0),
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1))
WHERE t.status = 'open' AND t.assign_to IN (?) AND t.due_date BETWEEN ? AND ?
ORDER BY t.due_date ASC`, [ids, lo, hi])
const FIELD_RE = /install|r[eé]paration|t[eé]l[eé]|monteur|fusion|d[eé]sinstall/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 (!(/est\.\s?time/i.test(subj) || FIELD_RE.test(t.dept || ''))) continue // terrain seulement
const tech = staffToTech[t.assign_to]; if (!tech) continue
const b = await buildJob(t)
b.payload.assigned_tech = tech; b.payload.status = 'assigned'
const ex = await findExisting(b.legacy_id)
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) }) }
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 })
} else {
const r = await erp.create('Dispatch Job', b.payload)
if (r && r.ok) created++; else { errors++; errSamples.push({ legacy_id: b.legacy_id, error: (r && r.error) || 'create' }) }
}
} 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, 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)
if (!b.matched.customer) unmatched++
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1
if (!b.matched.coords) noCoords++ // ni delivery ni Service Location ni RQA → routage indisponible (à diagnostiquer)
const ex = await findExisting(b.legacy_id)
if (ex) {
// Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT
// s'il est encore au pool (open + non assigné) → on ne clobbe jamais le travail du répartiteur.
const patch = {}
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
if (!ex.legacy_activation_url && b.payload.legacy_activation_url) patch.legacy_activation_url = b.payload.legacy_activation_url // backfill lien activation (sans risque)
if (b.payload.legacy_detail && ex.legacy_detail !== b.payload.legacy_detail) patch.legacy_detail = b.payload.legacy_detail // (re)backfill description + dates (idempotent : ne réécrit que si différent)
// Coords (localisation, sans risque pour l'ordonnancement) : on remplit si absentes/0 côté ERPNext,
// ET on UPGRADE vers les coords `delivery` (point de service exact) si elles diffèrent des coords
// existantes (souvent issues du Service Location, moins précises). delivery écrase ; SL/RQA non.
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
const exHas = hasCoord(ex.latitude) && hasCoord(ex.longitude)
// delivery (point exact) ET camping (géoloc fixe du camping vs résidence) ÉCRASENT des coords existantes différentes ; SL/RQA/Mapbox non.
const isUpgrade = (b.matched.coord_src === 'delivery' || b.matched.coord_src === 'camping') && exHas &&
(Math.abs(parseFloat(ex.latitude) - b.payload.latitude) > 1e-5 || Math.abs(parseFloat(ex.longitude) - b.payload.longitude) > 1e-5)
if (b.payload.latitude != null && (!exHas || isUpgrade)) { patch.latitude = b.payload.latitude; patch.longitude = b.payload.longitude; coordsFilled++ }
if (!ex.service_location && b.payload.service_location) patch.service_location = b.payload.service_location // backfill lien Service Location
// Adresse de SERVICE : backfill si jamais écrite (cas des 175 jobs historiques) ; et rafraîchit pour les jobs encore au pool (open + non assigné).
if (b.payload.address && (!ex.address || (ex.status === 'open' && !ex.assigned_tech && ex.address !== b.payload.address))) patch.address = b.payload.address
if (ex.status === 'open' && !ex.assigned_tech && b.payload.scheduled_date && b.payload.scheduled_date !== ex.scheduled_date) patch.scheduled_date = b.payload.scheduled_date
// Rafraîchit le sujet (qui inclut l'adresse de SERVICE) pour les jobs encore au pool (open + non assigné),
// sans surprendre un tech sur un job déjà dispatché. Corrige les sujets anciens basés sur la facturation.
if (ex.status === 'open' && !ex.assigned_tech && b.payload.subject && ex.subject !== b.payload.subject) patch.subject = b.payload.subject
if (!dryRun && Object.keys(patch).length) {
const r = await erp.update('Dispatch Job', ex.name, patch)
if (r && r.ok) { updated++; details.push({ legacy_id: b.legacy_id, action: 'update', job: ex.name, patch }) }
else { errors++; const msg = (r && r.error) || 'update failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'update', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'update-failed', job: ex.name, error: msg }) }
} else skipped++
} else if (dryRun) {
created++; details.push({ legacy_id: b.legacy_id, action: 'would-create', subject: b.payload.subject, job_type: b.payload.job_type, dept: b.dept, scheduled_date: b.payload.scheduled_date || null, start_time: b.payload.start_time || null, customer: b.matched.customer_name, customer_matched: b.matched.customer, sl_matched: b.matched.service_location, coords: b.matched.coords, coord_src: b.matched.coord_src, delivery_id: b.matched.delivery_id, addr: b.addr })
} else {
const r = await erp.create('Dispatch Job', b.payload)
if (r && r.ok) { created++; details.push({ legacy_id: b.legacy_id, action: 'created', job: r.name, subject: b.payload.subject, customer_matched: b.matched.customer }) }
else { errors++; const msg = (r && r.error) || 'create failed'; errSamples.push({ legacy_id: b.legacy_id, action: 'create', error: String(msg).slice(0, 200) }); details.push({ legacy_id: b.legacy_id, action: 'create-failed', error: msg }) }
}
} catch (e) {
errors++; details.push({ legacy_id: String(t.id), error: String((e && e.message) || e) })
}
}
let closedResolved = 0
if (!dryRun) { try { const cr = await closeResolved(); closedResolved = cr.closed } catch (e) { log('closeResolved error:', e.message) } } // retire les DJ dont le ticket legacy est fermé
const summary = { ok: true, dryRun, tech_staff_id: TARGO_TECH_STAFF_ID, tickets: tickets.length, created, updated, skipped, errors, unmatched_customer: unmatched, coords_filled: coordsFilled, no_coords: noCoords, coord_src: coordTally, error_samples: errSamples.slice(0, 6), closed: closedResolved }
if (!dryRun) { _lastRun = { at: new Date().toISOString(), ...summary }; log(`legacy-dispatch-sync: ${JSON.stringify(summary)}`) } // heartbeat
return { ...summary, details }
}
// Réconciliation : prouve qu'AUCUN ticket n'est échappé. Compare legacy(assign_to=3301, open) ↔ Dispatch Jobs (legacy_ticket_id).
async function reconcile () {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [rows] = await p.query('SELECT id FROM ticket WHERE status = ? AND assign_to = ?', ['open', TARGO_TECH_STAFF_ID])
const legacyIds = new Set((rows || []).map(r => String(r.id)))
// Dispatch Jobs issus du pont (legacy_ticket_id renseigné)
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech'], limit: 5000 })
const erpIds = new Set((djs || []).map(j => String(j.legacy_ticket_id)))
const missing = [...legacyIds].filter(id => !erpIds.has(id)) // legacy ouvert mais PAS dans ERPNext = échappé → à corriger
// orphelins = DJ encore "open"/non assigné dont le ticket legacy n'est plus ouvert(3301) (fermé/réassigné côté legacy)
const stillOpen = (djs || []).filter(j => j.status === 'open' && !j.assigned_tech)
const orphan = stillOpen.filter(j => !legacyIds.has(String(j.legacy_ticket_id))).map(j => ({ job: j.name, legacy_ticket_id: j.legacy_ticket_id }))
return { ok: true, legacy_open_3301: legacyIds.size, erpnext_bridged: erpIds.size, missing_count: missing.length, missing, orphan_count: orphan.length, orphan, last_sync: _lastRun }
}
// RÉIMPORT autoritaire des adresses de SERVICE depuis la table legacy `delivery` (point de service réel),
// pour TOUS les Dispatch Jobs issus du pont — y compris assignés/fermés que la sync (open + 3301) ne retouche pas.
// Écrit `address` (svcAddr delivery > billAddr) et remplit les coords MANQUANTES (0,0/NULL) depuis delivery
// (ne clobbe JAMAIS un job déjà géolocalisé → sécurité dispatch). Les jobs de CAMPING sont exclus : la `delivery`
// legacy y pointe la RÉSIDENCE du client (ce qu'on corrige justement par la géoloc fixe du camping).
// Passe par le verrou de sync (frappe_pg = zéro concurrence). dryRun => 0 écriture + échantillon des changements.
function reimportAddresses (opts = {}) {
const run = _syncLock.then(() => reimportAddressesImpl(opts), () => reimportAddressesImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
async function reimportAddressesImpl ({ dryRun = false, overwrite = true } = {}) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', '']], fields: ['name', 'legacy_ticket_id', 'subject', 'address', 'latitude', 'longitude', 'status', 'assigned_tech'], limit: 5000 })
const ids = [...new Set((jobs || []).map(j => parseInt(j.legacy_ticket_id, 10)).filter(Number.isFinite))]
if (!ids.length) return { ok: true, dryRun, jobs: 0, legacy_found: 0, address_updated: 0, coords_filled: 0, camping_skipped: 0, skipped: 0, errors: 0, samples: [] }
// Même jointure `delivery` que la sync (delivery_id du ticket → repli delivery avec coords → n'importe quel delivery du compte).
const [rows] = await p.query(
`SELECT t.id,
dv.address1 AS dv_addr, dv.city AS dv_city, dv.zip AS dv_zip, dv.latitude AS dv_lat, dv.longitude AS dv_lon,
a.address1, a.address2, a.city AS a_city, a.state, a.zip AS a_zip
FROM ticket t
LEFT JOIN account a ON a.id = t.account_id
LEFT JOIN delivery dv ON dv.id = COALESCE(
NULLIF(t.delivery_id, 0),
(SELECT d2.id FROM delivery d2 WHERE d2.account_id = t.account_id AND d2.latitude IS NOT NULL AND d2.latitude <> 0 AND ABS(d2.latitude) > 1 ORDER BY d2.id DESC LIMIT 1),
(SELECT d3.id FROM delivery d3 WHERE d3.account_id = t.account_id ORDER BY d3.id DESC LIMIT 1)
)
WHERE t.id IN (?)`,
[ids],
)
const byId = new Map((rows || []).map(r => [String(r.id), r]))
const campings = await getCampings()
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
let addrUpd = 0, coordFill = 0, skipped = 0, campSkip = 0, errors = 0
const samples = []
for (const j of jobs) {
const r = byId.get(String(j.legacy_ticket_id)); if (!r) { skipped++; continue }
if (campingFor(campings, [j.subject, r.dv_city, r.dv_addr])) { campSkip++; continue } // camping → résidence non pertinente, on garde la géoloc camping
const svcAddr = [r.dv_addr, r.dv_city, r.dv_zip].filter(Boolean).join(', ')
const billAddr = [r.address1, r.address2, r.a_city, r.state, r.a_zip].filter(Boolean).join(', ')
const addr = (svcAddr || billAddr).slice(0, 140)
const patch = {}
if (addr && (overwrite ? norm(addr) !== norm(j.address || '') : !j.address)) patch.address = addr
const dc = coord(r.dv_lat, r.dv_lon) // coords delivery valides (bornes QC) ?
if (dc && !(hasCoord(j.latitude) && hasCoord(j.longitude))) { patch.latitude = dc.lat; patch.longitude = dc.lon } // remplit SEULEMENT si manquantes
if (!Object.keys(patch).length) { skipped++; continue }
if (patch.address) addrUpd++
if (patch.latitude != null) coordFill++
if (samples.length < 15) samples.push({ job: j.name, ticket: j.legacy_ticket_id, status: j.status, src: svcAddr ? 'delivery' : 'billing', from: (j.address || '').slice(0, 45), to: patch.address || '(adresse inchangée)', coords_filled: patch.latitude != null })
if (!dryRun) { const res = await erp.update('Dispatch Job', j.name, patch); if (!(res && res.ok)) errors++ }
}
return { ok: true, dryRun, jobs: jobs.length, legacy_found: rows.length, address_updated: addrUpd, coords_filled: coordFill, camping_skipped: campSkip, skipped, errors, samples }
}
// REMPLIT les coords MANQUANTES (0,0/NULL) des Dispatch Jobs qui ont une adresse mais pas de GPS → « hors carte ».
// Priorité : coords de la Service Location liée (si valides) → géocodage RQA (trigram) → Mapbox. Ne clobbe JAMAIS
// un job déjà géolocalisé. Couvre TOUTE source (legacy ET jobs de test/seed). Verrou de sync. dryRun => 0 écriture.
function fillMissingCoords (opts = {}) {
const run = _syncLock.then(() => fillMissingCoordsImpl(opts), () => fillMissingCoordsImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
const PC_RE = /[A-Za-z]\d[A-Za-z]\s?\d[A-Za-z]\d/ // code postal canadien
function splitAddr (address) { // "ligne, ville, code postal" → { line, city, postal } (tolérant)
const parts = String(address || '').split(',').map(s => s.trim()).filter(Boolean)
const line = parts[0] || ''
const last = parts[parts.length - 1] || ''
const postal = PC_RE.test(last) ? last : ''
const city = postal ? (parts[parts.length - 2] || '') : (parts.length > 1 ? parts[parts.length - 1] : '')
return { line, city, postal }
}
async function fillMissingCoordsImpl ({ dryRun = false } = {}) {
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
const jobs = await erp.list('Dispatch Job', { filters: [['address', '!=', '']], fields: ['name', 'address', 'latitude', 'longitude', 'status', 'service_location'], limit: 5000 })
const targets = (jobs || []).filter(j => !(hasCoord(j.latitude) && hasCoord(j.longitude)))
if (!targets.length) return { ok: true, dryRun, candidates: 0, filled: 0, via_service_location: 0, via_rqa: 0, via_mapbox: 0, failed: 0, samples: [] }
// Précharge les coords des Service Locations liées (1 requête).
const slCodes = [...new Set(targets.map(j => j.service_location).filter(Boolean))]
const slCoord = new Map()
if (slCodes.length) {
const sls = await erp.list('Service Location', { filters: [['name', 'in', slCodes]], fields: ['name', 'latitude', 'longitude'], limit: slCodes.length + 10 })
for (const s of (sls || [])) if (hasCoord(s.latitude) && hasCoord(s.longitude)) slCoord.set(s.name, { lat: Number(s.latitude), lon: Number(s.longitude) })
}
let filled = 0, viaSL = 0, viaRQA = 0, viaMB = 0, failed = 0
const samples = []
for (const j of targets) {
let c = null, src = null
if (j.service_location && slCoord.has(j.service_location)) { c = slCoord.get(j.service_location); src = 'service_location' }
if (!c) { const { line, city, postal } = splitAddr(j.address); const g = await geocodeRQA(line, postal, city); if (g) { c = g; src = 'rqa' } }
if (!c) { const { line, city, postal } = splitAddr(j.address); const mb = await geocodeMapbox(line, city, postal); if (mb) { c = mb; src = 'mapbox' } }
if (!c) { failed++; if (samples.length < 15) samples.push({ job: j.name, address: (j.address || '').slice(0, 50), result: 'ÉCHEC géocodage' }); continue }
if (samples.length < 15) samples.push({ job: j.name, address: (j.address || '').slice(0, 50), result: `${c.lat.toFixed(5)},${c.lon.toFixed(5)} via ${src}` })
if (!dryRun) { const r = await erp.update('Dispatch Job', j.name, { latitude: c.lat, longitude: c.lon }); if (!(r && r.ok)) { failed++; continue } }
filled++; if (src === 'service_location') viaSL++; else if (src === 'rqa') viaRQA++; else viaMB++
}
return { ok: true, dryRun, candidates: targets.length, filled, via_service_location: viaSL, via_rqa: viaRQA, via_mapbox: viaMB, failed, samples }
}
// CORRIGE les coords MANQUANTES *ou HORS-TERRITOIRE* (homonymes mal géocodés : Gaspésie/Outaouais/Estrie).
// Chaîne TOUT-EN-TERRITOIRE : camping → coords Service Location → RQA rue (gardé territoire) → centroïde code
// postal → centroïde ville. ÉCRASE une coord hors-zone (c'est le bug) ; remplit une coord manquante. Verrou de sync.
function fixGeocoding (opts = {}) {
const run = _syncLock.then(() => fixGeocodingImpl(opts), () => fixGeocodingImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
async function fixGeocodingImpl ({ dryRun = false } = {}) {
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
const jobs = await erp.list('Dispatch Job', { filters: [['address', '!=', '']], fields: ['name', 'subject', 'address', 'latitude', 'longitude', 'status', 'service_location'], limit: 5000 })
// cibles = pas de coords valides EN territoire (manquantes OU hors-zone)
const targets = (jobs || []).filter(j => !(hasCoord(j.latitude) && hasCoord(j.longitude) && inTerritory(j.latitude, j.longitude)))
if (!targets.length) return { ok: true, dryRun, candidates: 0, fixed: 0, via_camping: 0, via_service_location: 0, via_rqa: 0, via_postal: 0, via_city: 0, failed: 0, samples: [] }
const campings = await getCampings()
const slCodes = [...new Set(targets.map(j => j.service_location).filter(Boolean))]
const slCoord = new Map()
if (slCodes.length) {
const sls = await erp.list('Service Location', { filters: [['name', 'in', slCodes]], fields: ['name', 'latitude', 'longitude'], limit: slCodes.length + 10 })
for (const s of (sls || [])) if (hasCoord(s.latitude) && hasCoord(s.longitude) && inTerritory(s.latitude, s.longitude)) slCoord.set(s.name, { lat: Number(s.latitude), lon: Number(s.longitude) })
}
let fixed = 0, viaCamp = 0, viaSL = 0, viaRQA = 0, viaPostal = 0, viaCity = 0, failed = 0
const samples = []
for (const j of targets) {
const { line, city, postal } = splitAddr(j.address)
let c = null, src = null
const camp = campingFor(campings, [j.subject, j.address])
if (camp && inTerritory(camp.latitude, camp.longitude)) { c = { lat: Number(camp.latitude), lon: Number(camp.longitude) }; src = 'camping' }
if (!c && j.service_location && slCoord.has(j.service_location)) { c = slCoord.get(j.service_location); src = 'service_location' }
if (!c) { const g = await geocodeRQA(line, postal, city); if (g) { c = g; src = 'rqa' } } // déjà gardé territoire
if (!c) { const ce = await geocodeCentroid(postal, city); if (ce) { c = ce; src = (String(postal || '').replace(/\s+/g, '').length >= 6) ? 'postal' : 'city' } }
if (!c) { failed++; if (samples.length < 20) samples.push({ job: j.name, address: (j.address || '').slice(0, 46), result: 'ÉCHEC (absent RQA)' }); continue }
if (samples.length < 20) samples.push({ job: j.name, address: (j.address || '').slice(0, 46), from: `${(+j.latitude || 0).toFixed(3)},${(+j.longitude || 0).toFixed(3)}`, to: `${c.lat.toFixed(4)},${c.lon.toFixed(4)} via ${src}` })
if (!dryRun) { const r = await erp.update('Dispatch Job', j.name, { latitude: c.lat, longitude: c.lon }); if (!(r && r.ok)) { failed++; continue } }
fixed++; if (src === 'camping') viaCamp++; else if (src === 'service_location') viaSL++; else if (src === 'rqa') viaRQA++; else if (src === 'postal') viaPostal++; else viaCity++
}
return { ok: true, dryRun, candidates: targets.length, fixed, via_camping: viaCamp, via_service_location: viaSL, via_rqa: viaRQA, via_postal: viaPostal, via_city: viaCity, failed, samples }
}
// PURGE des orphelins d'un ANCIEN import : jobs nommés `TT-<id>` SANS legacy_ticket_id dont le ticket legacy est
// `closed` (doublons/périmés — adresse réduite à la ville ; closeResolved ne peut PAS les voir faute de
// legacy_ticket_id). Supprime via l'API Frappe (nettoie les liens). NE touche JAMAIS un ticket encore `open`.
// Le record canonique reste côté legacy + les jobs `LEG-`. Verrou de sync. dryRun => 0 écriture.
function purgeStaleOrphans (opts = {}) {
const run = _syncLock.then(() => purgeStaleOrphansImpl(opts), () => purgeStaleOrphansImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
async function purgeStaleOrphansImpl ({ dryRun = false } = {}) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const jobs = await erp.list('Dispatch Job', { filters: [['name', 'like', 'TT-%']], fields: ['name', 'status', 'subject', 'legacy_ticket_id'], limit: 500 })
const withId = (jobs || [])
.filter(j => !j.legacy_ticket_id) // seulement les orphelins (les jobs du pont actuel ont un legacy_ticket_id → intouchables)
.map(j => ({ ...j, tid: (String(j.name).match(/^TT-(\d+)$/) || [])[1] }))
.filter(j => j.tid)
if (!withId.length) return { ok: true, dryRun, candidates: 0, closed: 0, deleted: 0, kept_open: 0, kept_open_detail: [], errors: 0, samples: [] }
const ids = [...new Set(withId.map(j => parseInt(j.tid, 10)))]
const [rows] = await p.query('SELECT id, status FROM ticket WHERE id IN (?)', [ids])
const st = {}; for (const r of rows) st[String(r.id)] = r.status
const stale = withId.filter(j => st[j.tid] === 'closed') // périmés = ticket legacy fermé
const keptOpen = withId.filter(j => st[j.tid] !== 'closed').map(j => ({ name: j.name, legacy_status: st[j.tid] || 'introuvable' }))
let deleted = 0, errors = 0
const samples = []
for (const j of stale) {
if (samples.length < 25) samples.push({ name: j.name, subject: (j.subject || '').slice(0, 40) })
if (!dryRun) { const r = await erp.remove('Dispatch Job', j.name); if (r && r.ok) deleted++; else errors++ }
}
return { ok: true, dryRun, candidates: withId.length, closed: stale.length, deleted, kept_open: keptOpen.length, kept_open_detail: keptOpen, errors, samples }
}
// WRITE-BACK vers le legacy osTicket : pousse l'assignation tech décidée dans Ops → `ticket.assign_to`.
// Mapping TECH-<id> → staff legacy <id>, VALIDÉ par concordance de NOM (le staff legacy doit porter le même nom
// que le tech Ops → évite de pousser un tech de test/système mal mappé). Ne touche QUE les tickets encore `open`.
// Idempotent (saute si assign_to est déjà le bon). C'est un WRITE EXPLICITE vers le legacy — jamais automatique
// (le scheduler ne l'appelle pas) ; uniquement via la route POST. Verrou de sync. dryRun => 0 écriture + aperçu.
function pushAssignments (opts = {}) {
const run = _syncLock.then(() => pushAssignmentsImpl(opts), () => pushAssignmentsImpl(opts))
_syncLock = run.then(() => {}, () => {})
return run
}
async function pushAssignmentsImpl ({ dryRun = false, actorEmail = '', notify = true } = {}) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
let actorStaff = 0 // staff legacy du répartiteur Ops (email Authentik → staff) = auteur du log/closed_by côté legacy
if (actorEmail) { try { const [ar] = await p.query('SELECT id FROM staff WHERE status=1 AND lower(email)=? LIMIT 1', [String(actorEmail).toLowerCase()]); if (ar && ar[0]) actorStaff = ar[0].id } catch (e) {} }
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['assigned_tech', '!=', '']], fields: ['name', 'legacy_ticket_id', 'assigned_tech', 'subject', 'status', 'address'], limit: 2000 })
if (!jobs.length) return { ok: true, dryRun, candidates: 0, pushed: 0, skipped: 0, mismatch: 0, errors: 0, notified: 0, samples: [] }
const techIds = [...new Set(jobs.map(j => j.assigned_tech))]
const techs = await erp.list('Dispatch Technician', { filters: [['name', 'in', techIds]], fields: ['name', 'full_name', 'email'], limit: techIds.length + 5 })
const techByName = {}; for (const t of (techs || [])) techByName[t.name] = t
// Mapping PRINCIPAL par EMAIL : staff.email (legacy) = Dispatch Technician.email (= identité Authentik via ldap_id). Fiable, sans parsing.
const emails = [...new Set((techs || []).map(t => String(t.email || '').toLowerCase()).filter(Boolean))]
const [emRows] = emails.length ? await p.query('SELECT id, first_name, last_name, status, lower(email) AS email FROM staff WHERE status = 1 AND lower(email) IN (?)', [emails]) : [[]]
const staffByEmail = new Map((emRows || []).map(s => [s.email, s]))
// Repli : staff déduit de TECH-<id>, VALIDÉ par concordance de nom (pour les techs sans email).
const sufIds = [...new Set(jobs.map(j => (String(j.assigned_tech).match(/(\d{2,})$/) || [])[1]).filter(Boolean))]
const [sufRows] = sufIds.length ? await p.query('SELECT id, first_name, last_name, status FROM staff WHERE id IN (?)', [sufIds]) : [[]]
const staffById = new Map((sufRows || []).map(s => [String(s.id), s]))
const ids = [...new Set(jobs.map(j => parseInt(j.legacy_ticket_id, 10)).filter(Boolean))]
const [tk] = ids.length ? await p.query('SELECT id, status, assign_to FROM ticket WHERE id IN (?)', [ids]) : [[]]
const tkById = new Map((tk || []).map(r => [String(r.id), r]))
// Résout le staff legacy : email d'abord (autoritaire), sinon TECH-<id> validé par nom.
const resolveStaff = (techRow, assignedTech) => {
const email = String((techRow && techRow.email) || '').toLowerCase()
if (email && staffByEmail.has(email)) return { staff: staffByEmail.get(email), via: 'email' }
const sid = (String(assignedTech).match(/(\d{2,})$/) || [])[1]
const cand = sid ? staffById.get(sid) : null
if (cand) {
const a = norm((techRow && techRow.full_name) || ''); const b = norm((cand.first_name || '') + ' ' + (cand.last_name || ''))
if (a && b && (a === b || a.includes(b) || b.includes(a) || (a.split(' ')[0] === b.split(' ')[0] && a.split(' ').slice(-1)[0] === b.split(' ').slice(-1)[0]))) return { staff: cand, via: 'nom' }
}
return { staff: null, via: null }
}
let pushed = 0, skipped = 0, mismatch = 0, locked = 0, errors = 0, notified = 0; const errSamples = []; const notifyErrors = []
const samples = []
for (const j of jobs) {
const techRow = techByName[j.assigned_tech]
const { staff: st, via } = resolveStaff(techRow, j.assigned_tech)
const tkrow = tkById.get(String(j.legacy_ticket_id))
if (!st || Number(st.status) !== 1) { mismatch++; if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, tech: j.assigned_tech, ops_nom: (techRow && techRow.full_name) || '(?)', action: '⚠ staff legacy introuvable (ni email ni nom) — ignoré' }); continue }
const staffId = String(st.id)
if (!tkrow || tkrow.status === 'closed') { skipped++; continue } // ticket fermé/introuvable → on ne réassigne pas
if (String(tkrow.assign_to) === staffId) { skipped++; continue } // déjà assigné au bon tech → idempotent
if (samples.length < 25) samples.push({ job: j.name, ticket: j.legacy_ticket_id, de_staff: tkrow.assign_to, vers_staff: staffId + ' (' + (st.first_name || '') + ' ' + (st.last_name || '') + ')', via, sujet: (j.subject || '').slice(0, 34) })
if (!dryRun) {
const w = await legacyWrite({ action: 'reassign', ticket_id: j.legacy_ticket_id, staff_id: staffId, actor_staff_id: actorStaff || '', notify: notify ? 1 : '', address: j.address || '' }).catch(e => ({ data: { ok: false, error: e.message } }))
const d = (w && w.data) || {}
if (d.ok) { pushed++; if (d.notified) notified++; else if (notify && d.notify_error && notifyErrors.length < 6) notifyErrors.push({ ticket: j.legacy_ticket_id, to: d.notify_to || '', error: d.notify_error }) }
else if (d.error === 'verrouillé') { locked++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, locked_by: d.locked_by }) }
else { errors++; if (errSamples.length < 6) errSamples.push({ ticket: j.legacy_ticket_id, error: d.error || ('http ' + (w && w.status)) }) }
} else pushed++
}
return { ok: true, dryRun, candidates: jobs.length, pushed, skipped, mismatch, locked, errors, notified, notify_errors: notifyErrors, error_samples: errSamples, samples }
}
// Auto-fermeture : un Dispatch Job issu du pont dont le ticket legacy est passé `closed` → on le marque « Completed »
// (sort du pool / des listes ouvertes). NE touche PAS « In Progress » (tech en action). SÉQUENTIEL.
async function closeResolved () {
const p = pool(); if (!p) return { checked: 0, closed: 0 }
const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold']]], fields: ['name', 'legacy_ticket_id', 'status'], limit: 5000 })
if (!djs.length) return { checked: 0, closed: 0 }
const ids = [...new Set(djs.map(j => parseInt(j.legacy_ticket_id)).filter(Boolean))]
const [rows] = await p.query('SELECT id, status FROM ticket WHERE id IN (?)', [ids])
const st = {}; for (const r of rows) st[String(r.id)] = r.status
let closed = 0; const details = []
for (const j of djs) {
if (st[j.legacy_ticket_id] === 'closed') { // fermé côté legacy → on retire d'ERPNext
try { await erp.update('Dispatch Job', j.name, { status: 'Completed' }); closed++; details.push({ job: j.name, legacy_ticket_id: j.legacy_ticket_id }) } catch (e) {}
}
}
return { checked: djs.length, closed, details }
}
// Fil COMPLET d'un ticket legacy (description + commentaires/réponses des collaborateurs) — read-only.
async function ticketThread (legacyId) {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const id = String(legacyId || '').replace(/[^0-9]/g, ''); if (!id) return { ok: false, error: 'id invalide' }
const [trows] = await p.query('SELECT subject, status FROM ticket WHERE id = ? LIMIT 1', [id])
const [rows] = await p.query(
`SELECT mm.id, mm.date_orig, mm.staff_id, s.first_name, s.last_name, s.username, mm.msg
FROM ticket_msg mm LEFT JOIN staff s ON s.id = mm.staff_id
WHERE mm.ticket_id = ? ORDER BY mm.id ASC LIMIT 200`, [id])
const messages = (rows || []).map(r => ({
at: r.date_orig ? new Date(Number(r.date_orig) * 1000).toISOString() : null,
author: [r.first_name, r.last_name].filter(Boolean).join(' ') || r.username || (r.staff_id ? ('Staff ' + r.staff_id) : 'Système / client'),
text: stripHtml(r.msg, 6000),
})).filter(m => m.text)
return { ok: true, ticket: id, subject: (trows && trows[0] && trows[0].subject) || '', status: (trows && trows[0] && trows[0].status) || '', count: messages.length, messages }
}
// ── VEILLE Legacy → Ops (poll léger → SSE) ──
// Lecture seule (sauf auto-close immédiat, idempotent) : compare l'état legacy (status/assign_to/last_update) de TOUS
// les tickets suivis à un snapshot mémoire, et DIFFUSE les deltas sur le topic SSE 'dispatch' (l'Ops rafraîchit en direct).
// Détecte : fermeture (→ marque le Dispatch Job Completed tout de suite), réassignation hors-Ops, changement de statut,
// nouvelle activité (last_update). Cadence courte (≈3 min) ≠ la sync d'import (15 min). Pas de verrou (read-only legacy+ERP).
const _watchSnap = new Map() // ticket_id → { status, assign_to, last_update }
let _watchTimer = null
let _lastWatch = null
async function watchLegacy () {
const p = pool(); if (!p) return { ok: false, error: 'mysql2 indisponible' }
const jobs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '!=', ''], ['status', 'in', ['open', 'assigned', 'On Hold', 'In Progress']]], fields: ['name', 'legacy_ticket_id', 'status', 'assigned_tech', 'subject'], limit: 5000 })
const jobByTk = new Map(); const ids = []
for (const j of (jobs || [])) { const id = String(j.legacy_ticket_id); jobByTk.set(id, j); const n = parseInt(id, 10); if (n) ids.push(n) }
if (!ids.length) { _lastWatch = { at: new Date().toISOString(), tracked: 0, changes: 0 }; return { ok: true, tracked: 0, changes: [] } }
const [rows] = await p.query('SELECT id, status, assign_to, last_update FROM ticket WHERE id IN (?)', [ids])
const seeded = _watchSnap.size > 0 // 1er passage (snapshot vide) = amorçage silencieux des DELTAS
const changes = []; let closedNow = 0; let pulled = 0
for (const r of (rows || [])) {
const id = String(r.id)
const prev = _watchSnap.get(id)
const cur = { status: r.status, assign_to: String(r.assign_to), last_update: Number(r.last_update) || 0 }
_watchSnap.set(id, cur)
const j = jobByTk.get(id); if (!j) continue
// (1) RÉCONCILIATION D'ÉTAT (à CHAQUE passage, même amorçage) : un job ENCORE dans le pool (open/On Hold, non
// assigné côté Ops) dont le ticket legacy est PRIS par un VRAI employé (≠3301, ≠0) et pas fermé → on le RETIRE
// du pool (status Cancelled). Couvre les tickets assignés dans Legacy AVANT le démarrage de la veille (ex. 250095).
const at = parseInt(cur.assign_to, 10) || 0
if (!j.assigned_tech && (j.status === 'open' || j.status === 'On Hold') && cur.status !== 'closed' && at > 0 && at !== TARGO_TECH_STAFF_ID) {
try { await erp.update('Dispatch Job', j.name, { status: 'Cancelled' }); pulled++; changes.push({ ticket: id, job: j.name, kind: 'taken', to_staff: cur.assign_to, subject: j.subject || '' }) } catch (e) {}
continue // sorti du pool → pas d'autre delta à émettre
}
// (2) DELTAS diffusés — seulement une fois le snapshot amorcé
if (!seeded || !prev) continue
if (prev.status !== cur.status) {
if (cur.status === 'closed') {
try { await erp.update('Dispatch Job', j.name, { status: 'Completed' }); closedNow++ } catch (e) {} // reflète tout de suite (idempotent)
changes.push({ ticket: id, job: j.name, kind: 'closed', subject: j.subject || '' })
} else changes.push({ ticket: id, job: j.name, kind: 'status', from: prev.status, to: cur.status, subject: j.subject || '' })
} else if (prev.assign_to !== cur.assign_to) {
changes.push({ ticket: id, job: j.name, kind: 'reassigned', to_staff: cur.assign_to, pool: cur.assign_to === String(TARGO_TECH_STAFF_ID), tech: j.assigned_tech || '', subject: j.subject || '' })
} else if (prev.last_update !== cur.last_update) {
changes.push({ ticket: id, job: j.name, kind: 'activity', subject: j.subject || '' })
}
}
if (changes.length) sse.broadcast('dispatch', 'legacy-update', { at: new Date().toISOString(), changes })
_lastWatch = { at: new Date().toISOString(), tracked: ids.length, seeded, closed: closedNow, pulled, changes: changes.length }
return { ok: true, tracked: ids.length, seeded, closed: closedNow, pulled, changes }
}
// ── RÉCONCILIATION DES TECHNICIENS (union des 3 systèmes) — RAPPORT + apply manuel ──────────────────────────
// 3 sources : staff legacy actif ↔ Dispatch Technician (ERPNext, TECH-<id>) ↔ groupe Authentik 'tech' (accès Ops).
// Le rapport liste les ÉCARTS ; aucune écriture Authentik. L'apply CRÉE des fiches Dispatch Technician (ERPNext) pour
// les staff choisis (le répartiteur coche). Matching par EMAIL pour l'accès, par id (TECH-<id>) pour la fiche.
async function akGet (path) {
if (!cfg.AUTHENTIK_TOKEN) return null
const r = await httpRequest(cfg.AUTHENTIK_URL || 'https://auth.targo.ca', '/api/v3' + path, { method: 'GET', headers: { Authorization: 'Bearer ' + cfg.AUTHENTIK_TOKEN }, timeout: 12000 }).catch(() => null)
return r && r.data
}
async function techGroupEmailSet () {
const g = await akGet('/core/groups/?search=tech')
const set = new Set()
if (g && Array.isArray(g.results)) {
const grp = g.results.find(x => x.name === 'tech') || g.results[0]
for (const u of (grp && grp.users_obj) || []) { const e = (u.email || '').toLowerCase().trim(); if (e) set.add(e) }
return { ok: true, emails: set }
}
return { ok: false, emails: set }
}
async function techSyncReport () {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [staff] = await p.query("SELECT id, first_name, last_name, lower(trim(email)) email FROM staff WHERE status=1")
const techs = await erp.list('Dispatch Technician', { filters: [['resource_type', '=', 'human']], fields: ['name', 'technician_id', 'full_name', 'status', 'email'], limit: 500 })
const ficheStaffIds = new Set(); const ficheEmails = new Set()
for (const t of techs) { const sid = (String(t.technician_id || t.name).match(/(\d+)$/) || [])[1]; if (sid) ficheStaffIds.add(sid); if (t.email) ficheEmails.add(String(t.email).toLowerCase().trim()) }
const ak = await techGroupEmailSet()
const staff_missing_fiche = []
for (const s of (staff || [])) {
if (ficheStaffIds.has(String(s.id))) continue
if (s.email && ficheEmails.has(s.email)) continue // déjà une fiche (matchée par courriel)
staff_missing_fiche.push({ staff_id: s.id, name: [s.first_name, s.last_name].filter(Boolean).join(' ') || ('staff ' + s.id), email: s.email || '', in_tech_group: !!(s.email && ak.emails.has(s.email)) })
}
const fiche_no_access = ak.ok ? techs.filter(t => { const e = (t.email || '').toLowerCase().trim(); return e && !ak.emails.has(e) }).map(t => ({ technician_id: t.technician_id, name: t.full_name, email: t.email })) : []
const fiche_no_email = techs.filter(t => !t.email).map(t => ({ technician_id: t.technician_id, name: t.full_name }))
const access_no_fiche = ak.ok ? [...ak.emails].filter(e => !ficheEmails.has(e)).sort() : []
return {
ok: true, authentik: ak.ok,
counts: { staff_active: (staff || []).length, fiches: techs.length, tech_group: ak.emails.size, missing_fiche: staff_missing_fiche.length, no_access: fiche_no_access.length, access_no_fiche: access_no_fiche.length },
staff_missing_fiche: staff_missing_fiche.sort((a, b) => b.staff_id - a.staff_id),
fiche_no_access, fiche_no_email, access_no_fiche,
}
}
async function techSyncApply (staffIds) {
const ids = [...new Set((staffIds || []).map(x => parseInt(x, 10)).filter(Boolean))]
if (!ids.length) return { ok: true, created: 0, results: [] }
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [rows] = await p.query('SELECT id, first_name, last_name, email FROM staff WHERE id IN (?) AND status=1', [ids])
const byId = new Map((rows || []).map(r => [String(r.id), r]))
let created = 0; const results = []
for (const id of ids) { // SÉQUENTIEL (frappe_pg)
const s = byId.get(String(id)); if (!s) { results.push({ staff_id: id, ok: false, error: 'staff inactif/introuvable' }); continue }
const tid = 'TECH-' + id
const ex = await erp.list('Dispatch Technician', { filters: [['technician_id', '=', tid]], fields: ['name'], limit: 1 })
if (ex && ex.length) { results.push({ staff_id: id, ok: true, skipped: 'existe déjà', technician_id: tid }); continue }
const body = { technician_id: tid, full_name: [s.first_name, s.last_name].filter(Boolean).join(' ') || tid, resource_type: 'human', status: 'Disponible', efficiency: 1 }
if (s.email) body.email = s.email
try { await erp.create('Dispatch Technician', body); created++; results.push({ staff_id: id, ok: true, technician_id: tid }) } catch (e) { results.push({ staff_id: id, ok: false, error: String(e.message || e) }) }
}
return { ok: true, created, results }
}
// MINEUR de durées (baseline d'apprentissage SANS app) : approxime le temps sur-site par la fenêtre des réponses STAFF
// d'un même ticket le même jour (< 8 h). Bruité à l'unité mais l'agrégat (médiane par dept/type) donne un point de départ.
async function mineDurations () {
const p = pool(); if (!p) throw new Error('mysql2 indisponible sur le hub')
const [rows] = await p.query(
`SELECT t.dept_id, (MAX(m.date_orig) - MIN(m.date_orig)) AS span
FROM ticket t JOIN ticket_msg m ON m.ticket_id = t.id
WHERE t.status='closed' AND t.date_closed > UNIX_TIMESTAMP() - 180*86400 AND m.staff_id > 0
GROUP BY t.id, t.dept_id HAVING span > 120 AND span < 8*3600`)
const byDept = {}
for (const r of (rows || [])) { (byDept[r.dept_id] || (byDept[r.dept_id] = [])).push(Number(r.span) / 60) }
const out = []
for (const dept in byDept) {
const arr = byDept[dept].sort((a, b) => a - b); if (arr.length < 3) continue
out.push({ dept_id: Number(dept), job_type: jobType(Number(dept)), n: arr.length, median_min: Math.round(arr[Math.floor(arr.length / 2)]), p75_min: Math.round(arr[Math.floor(arr.length * 0.75)]) })
}
out.sort((a, b) => b.n - a.n)
return { ok: true, note: 'Proxy bruité (fenêtre réponses staff même jour <8h, 180j). Baseline en attendant la capture GPS/app.', by_dept: out.slice(0, 25) }
}
// ── Récurrence (setInterval) ──
let _timer = null
let _lastRun = null // heartbeat : dernier passage réussi (pour /status + Uptime-Kuma)
function startSync () {
// OPT-IN : la récurrence ne démarre QUE si LEGACY_DISPATCH_SYNC ∈ {on,1,true}.
// (Évite toute écriture automatique surprise au boot ; preview/run manuels restent dispo via les routes.)
if (!/^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || ''))) { log('legacy-dispatch-sync: récurrence désactivée (poser LEGACY_DISPATCH_SYNC=on pour activer)'); return }
if (!mysql) { log('legacy-dispatch-sync: mysql2 absent → pont inactif'); return }
const minutes = Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15
const tick = () => sync({ dryRun: false }).catch(e => log('legacy-dispatch-sync tick error:', e.message))
// 1er passage différé (laisse le boot se stabiliser), puis toutes les `minutes`.
setTimeout(tick, 90 * 1000)
_timer = setInterval(tick, minutes * 60 * 1000)
log(`legacy-dispatch-sync: pont actif (toutes les ${minutes} min, staff ${TARGO_TECH_STAFF_ID})`)
// VEILLE temps-réel (poll léger → SSE) : cadence courte, indépendante de l'import.
const watchMin = Number(process.env.LEGACY_DISPATCH_WATCH_MIN) || 3
const wtick = () => watchLegacy().catch(e => log('legacy-watch error:', e.message))
setTimeout(wtick, 60 * 1000) // amorçage du snapshot ~1 min après le boot
_watchTimer = setInterval(wtick, watchMin * 60 * 1000)
log(`legacy-watch: veille active (toutes les ${watchMin} min → SSE topic 'dispatch')`)
}
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } if (_watchTimer) { clearInterval(_watchTimer); _watchTimer = null } }
// 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 = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&quot;/g, '"').replace(/&#(\d+);/g, (_, n) => { try { return String.fromCodePoint(+n) } catch (e) { return '' } })
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 = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
// 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
return { ok: true, load }
}
async function handle (req, res, method, path) {
try {
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
if (path === '/dispatch/legacy-sync/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/ingest-assigned' && method === 'GET') return json(res, 200, await ingestAssigned({ dryRun: true })) // APERÇU (0 écriture)
if (path === '/dispatch/legacy-sync/ingest-assigned' && method === 'POST') { // INGÈRE : crée les Dispatch Job assignés (terrain, fenêtre)
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 }))
}
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'GET') return json(res, 200, await reimportAddresses({ dryRun: true })) // aperçu (0 écriture)
if (path === '/dispatch/legacy-sync/reimport-addresses' && method === 'POST') return json(res, 200, await reimportAddresses({ dryRun: false })) // applique
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'GET') return json(res, 200, await fillMissingCoords({ dryRun: true })) // aperçu géocodage des « hors carte »
if (path === '/dispatch/legacy-sync/fill-coords' && method === 'POST') return json(res, 200, await fillMissingCoords({ dryRun: false })) // applique
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'GET') return json(res, 200, await fixGeocoding({ dryRun: true })) // aperçu correction coords manquantes/hors-zone
if (path === '/dispatch/legacy-sync/fix-geocoding' && method === 'POST') return json(res, 200, await fixGeocoding({ dryRun: false })) // applique
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'GET') return json(res, 200, await pushAssignments({ dryRun: true })) // APERÇU write-back tech → legacy (0 écriture)
if (path === '/dispatch/legacy-sync/push-assignments' && method === 'POST') { // ÉCRIT ticket.assign_to + log attribué à l'acteur Authentik ; notify=0 coupe l'avis courriel
const notify = new URL(req.url, 'http://localhost').searchParams.get('notify') !== '0'
return json(res, 200, await pushAssignments({ dryRun: false, actorEmail: req.headers['x-authentik-email'] || '', notify }))
}
if (path === '/dispatch/legacy-sync/close-ticket' && method === 'POST') { // ferme un ticket legacy + marque le Dispatch Job Completed
const id = new URL(req.url, 'http://localhost').searchParams.get('ticket'); if (!id) return json(res, 400, { ok: false, error: 'ticket requis' })
const r = await closeTicketLegacy(id, req.headers['x-authentik-email'] || '')
if (r && r.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(id)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} }
return json(res, 200, r)
}
if (path === '/dispatch/legacy-sync/batch-close' && method === 'POST') { // ferme N tickets legacy en 1 appel + marque les Dispatch Jobs Completed
const ids = (new URL(req.url, 'http://localhost').searchParams.get('tickets') || '').split(',').map(s => s.trim()).filter(Boolean)
if (!ids.length) return json(res, 400, { ok: false, error: 'tickets requis' })
const r = await batchCloseLegacy(ids, req.headers['x-authentik-email'] || '')
if (r && r.ok && Array.isArray(r.results)) { for (const it of r.results) { if (it.ok) { try { const djs = await erp.list('Dispatch Job', { filters: [['legacy_ticket_id', '=', String(it.t)]], fields: ['name'], limit: 1 }); if (djs && djs[0]) await erp.update('Dispatch Job', djs[0].name, { status: 'Completed' }) } catch (e) {} } } }
return json(res, 200, r)
}
if (path === '/dispatch/legacy-sync/return-to-pool' && method === 'POST') { // désassigne (ERPNext) + retour pool 3301 (legacy) — action EXPLICITE
const job = new URL(req.url, 'http://localhost').searchParams.get('job'); if (!job) return json(res, 400, { ok: false, error: 'job requis' })
return json(res, 200, await returnToPool(job, req.headers['x-authentik-email'] || ''))
}
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'GET') return json(res, 200, await purgeStaleOrphans({ dryRun: true })) // aperçu purge orphelins TT- périmés
if (path === '/dispatch/legacy-sync/purge-orphans' && method === 'POST') return json(res, 200, await purgeStaleOrphans({ dryRun: false })) // supprime
if (path === '/dispatch/legacy-sync/watch' && method === 'GET') return json(res, 200, await watchLegacy()) // poll manuel + deltas (diffuse aussi sur SSE 'dispatch')
if (path === '/dispatch/legacy-sync/tech-sync' && method === 'GET') return json(res, 200, await techSyncReport()) // rapport réconciliation techs (3 systèmes)
if (path === '/dispatch/legacy-sync/mine-durations' && method === 'GET') return json(res, 200, await mineDurations()) // baseline durées apprises (proxy réponses staff)
if (path === '/dispatch/legacy-sync/tech-sync/apply' && method === 'POST') { // crée les fiches Dispatch Technician choisies (ERPNext only)
const ids = (new URL(req.url, 'http://localhost').searchParams.get('ids') || '').split(',').map(s => s.trim()).filter(Boolean)
return json(res, 200, await techSyncApply(ids))
}
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
if (path === '/dispatch/legacy-sync/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)) }
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 = (s) => String(s == null ? '' : s).replace(/&#0?39;/g, "'").replace(/&amp;/g, '&').replace(/&#(\d+);/g, (_, k) => { try { return String.fromCodePoint(+k) } catch (e) { return '' } })
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 }
}
module.exports = { handle, sync, reimportAddresses, fillMissingCoords, fixGeocoding, purgeStaleOrphans, pushAssignments, returnToPool, watchLegacy, techSyncReport, techSyncApply, mineDurations, legacyTechTickets, legacyWindowLoad, ingestAssigned, reconcileLegacyJobs, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType, inTerritory } // parseurs purs exposés pour les tests