Modularisation / dé-duplication : - lib/util/text.js : `norm` canonique partagé (remplace 2 ré-implémentations : address-db, legacy-dispatch-sync). - lib/util/legacy-parse.js : parseurs/mapping PURS du pont (DEPT_JOBTYPE, DUR, jobType, prio, tzDate, startTime, coord) extraits hors I/O → testables en isolation, sans pg/mysql/erp. - legacy-dispatch-sync + address-db importent ces utils (pont vérifié en prod : preview OK, 0 erreur). Observabilité (sûr, additif, 1 seul point) : - erp.js create/update/remove : log de l'échec à la SOURCE quand HTTP≥400 → toutes les écritures ERPNext silencieuses des 50+ appelants sont désormais tracées, SANS changer aucun flux de contrôle. Tests (fondation) : - vitest + npm test ; test/util.test.js : 19 tests verts sur norm + coord(bornes QC)/prio/startTime/jobType/tzDate. Tournent sans installer les deps lourdes du hub (modules purs). Aligné docs/architecture/VISION.md (P0 hygiène). Suite : audit r.ok des appelants financiers (payments/contracts) en revue supervisée ; CI/CD minimal (Gitea Actions lint+test) ; décomposition des god-files (Phase 2). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
398 lines
26 KiB
JavaScript
398 lines
26 KiB
JavaScript
'use strict'
|
|
/**
|
|
* legacy-dispatch-sync.js — PONT legacy (osTicket/MariaDB) → Dispatch Job (ERPNext).
|
|
*
|
|
* Tire RÉGULIÈREMENT les tickets ouverts assignés au compte « Tech Targo »
|
|
* (staff id 3301 dans la DB legacy `gestionclient`) et crée/maj un Dispatch Job
|
|
* dans ERPNext pour les répartir sur la grille Planification / le tableau Dispatch.
|
|
*
|
|
* Pourquoi 3301 : dans le legacy, le travail terrain à dispatcher est assigné au
|
|
* compte générique « Tech Targo » (default_staff des dépts Installation/Réparation/
|
|
* Fibre). C'est exactement « les tickets assignés à tech targo ».
|
|
*
|
|
* IDEMPOTENT : chaque ticket legacy porte un `legacy_ticket_id` sur le Dispatch Job.
|
|
* On cherche avant de créer → jamais de doublon. On NE clobbe PAS le travail du
|
|
* répartiteur : un job déjà assigné/déplacé n'est plus touché (maj de date seulement
|
|
* tant qu'il est encore `open` + non assigné).
|
|
*
|
|
* Routes : GET /dispatch/legacy-sync/preview (dry-run, 0 écriture) · POST /dispatch/legacy-sync/run
|
|
* Récurrence : startSync() (setInterval, cf. server.js), désactivable via LEGACY_DISPATCH_SYNC=off.
|
|
*
|
|
* Pré-requis : champ Custom Field `legacy_ticket_id` sur Dispatch Job
|
|
* (dispatch-app/frappe-setup/setup_dispatch_custom_fields.py).
|
|
*/
|
|
const erp = require('./erp')
|
|
const cfg = require('./config')
|
|
const { log, json, httpRequest } = require('./helpers')
|
|
const { searchAddressesRpc } = require('./address-search') // recherche trigram RQA (RPC pg_trgm) — celle de l'autocomplete de dispo
|
|
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).
|
|
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)) return false
|
|
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) res = c
|
|
}
|
|
} catch (e) { log('geocodeMapbox error:', e.message) }
|
|
_mbCache.set(key, res)
|
|
return res
|
|
}
|
|
|
|
let _pool
|
|
function pool () {
|
|
if (!mysql) return null
|
|
if (!_pool) {
|
|
_pool = mysql.createPool({
|
|
host: cfg.LEGACY_DB_HOST, user: cfg.LEGACY_DB_USER, password: cfg.LEGACY_DB_PASS, database: cfg.LEGACY_DB_NAME,
|
|
connectionLimit: 2, waitForConnections: true, connectTimeout: 8000,
|
|
})
|
|
}
|
|
return _pool
|
|
}
|
|
|
|
// Lien d'activation STB/Ministra : DÉJÀ posté dans le fil du ticket par le wizard legacy à la vente.
|
|
// On le ré-extrait tel quel (zéro reconstruction). Sous-requête = le ticket_msg le plus récent qui le contient.
|
|
const ACTIVATION_RE = /https?:\/\/[^\s"'<>]*connect_ministra\.php[^\s"'<>]*/i
|
|
function extractActivationUrl (msg) { if (!msg) return ''; const m = String(msg).match(ACTIVATION_RE); return m ? m[0] : '' }
|
|
// Détail du ticket = 1er message du fil legacy (HTML osTicket) → texte lisible, tronqué, pour l'afficher dans Ops.
|
|
function stripHtml (html, max = 1500) {
|
|
if (!html) return ''
|
|
let s = String(html)
|
|
.replace(/<\s*br\s*\/?\s*>/gi, '\n').replace(/<\/\s*(p|div|li|tr)\s*>/gi, '\n')
|
|
.replace(/<[^>]+>/g, '')
|
|
.replace(/ /gi, ' ').replace(/&/gi, '&').replace(/</gi, '<').replace(/>/gi, '>')
|
|
.replace(/�*39;|'|'/gi, "'").replace(/"/gi, '"')
|
|
.replace(/[ \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 = t.delivery_id
|
|
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(' — '))
|
|
if (!sl && addr) subject = (subject + ' · ' + addr) // pas de Service Location → on garde l'adresse visible dans le sujet
|
|
subject = subject.slice(0, 140) // Subject = champ Data Frappe (max 140 car.) ; le détail complet est dans legacy_detail/coords
|
|
|
|
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…)
|
|
}
|
|
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 = '🗓 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
|
|
if (dc) { payload.latitude = dc.lat; payload.longitude = dc.lon; coordSrc = 'delivery' } // source fiable : 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', 'legacy_dept', 'legacy_activation_url', 'legacy_detail', 'latitude', 'longitude', 'service_location'], limit: 1 })
|
|
return (r && r[0]) || null
|
|
}
|
|
|
|
// VERROU de sérialisation : frappe_pg ne supporte pas la concurrence. Le tick récurrent ET les runs
|
|
// manuels (preview/run) passent tous par `sync()` → on les met en FILE pour qu'ils ne se chevauchent
|
|
// JAMAIS (sinon « socket hang up » + écritures perdues dans un rollback). Chaque appel attend le précédent.
|
|
let _syncLock = Promise.resolve()
|
|
function sync (opts = {}) {
|
|
const run = _syncLock.then(() => syncImpl(opts), () => syncImpl(opts))
|
|
_syncLock = run.then(() => {}, () => {}) // le suivant attend, quel que soit le résultat
|
|
return run
|
|
}
|
|
|
|
// Cœur : parcourt les tickets, crée/maj les Dispatch Jobs. SÉQUENTIEL (frappe_pg ne supporte pas la concurrence).
|
|
async function syncImpl ({ dryRun = false } = {}) {
|
|
resetCaches()
|
|
const tickets = await fetchTargoTickets()
|
|
let created = 0, updated = 0, skipped = 0, errors = 0, unmatched = 0, coordsFilled = 0, noCoords = 0
|
|
const coordTally = {} // observabilité : répartition des sources de coords (delivery/service_location/rqa_geocode/none)
|
|
const errSamples = [] // observabilité : échantillon des erreurs create/update (« ne rien échapper »)
|
|
const details = []
|
|
for (const t of tickets) {
|
|
try {
|
|
const b = await buildJob(t)
|
|
if (!b.matched.customer) unmatched++
|
|
coordTally[b.matched.coord_src || 'none'] = (coordTally[b.matched.coord_src || 'none'] || 0) + 1
|
|
if (!b.matched.coords) noCoords++ // ni delivery ni Service Location ni RQA → routage indisponible (à diagnostiquer)
|
|
const ex = await findExisting(b.legacy_id)
|
|
if (ex) {
|
|
// Déjà importé. Backfill du département (métadonnée couleur, sans risque) + maj date SEULEMENT
|
|
// s'il est encore au pool (open + non assigné) → on ne clobbe jamais le travail du répartiteur.
|
|
const patch = {}
|
|
if (!ex.legacy_dept && b.payload.legacy_dept) patch.legacy_dept = b.payload.legacy_dept
|
|
if (!ex.legacy_activation_url && b.payload.legacy_activation_url) patch.legacy_activation_url = b.payload.legacy_activation_url // backfill lien activation (sans risque)
|
|
if (b.payload.legacy_detail && ex.legacy_detail !== b.payload.legacy_detail) patch.legacy_detail = b.payload.legacy_detail // (re)backfill description + dates (idempotent : ne réécrit que si différent)
|
|
// Coords (localisation, sans risque pour l'ordonnancement) : on remplit si absentes/0 côté ERPNext,
|
|
// ET on UPGRADE vers les coords `delivery` (point de service exact) si elles diffèrent des coords
|
|
// existantes (souvent issues du Service Location, moins précises). delivery écrase ; SL/RQA non.
|
|
const hasCoord = (v) => v != null && v !== '' && Math.abs(parseFloat(v)) > 0.0001
|
|
const exHas = hasCoord(ex.latitude) && hasCoord(ex.longitude)
|
|
const isDeliveryUpgrade = b.matched.coord_src === 'delivery' && 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 || isDeliveryUpgrade)) { 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
|
|
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
|
|
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 }
|
|
}
|
|
|
|
// 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 }
|
|
}
|
|
|
|
// ── 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})`)
|
|
}
|
|
function stopSync () { if (_timer) { clearInterval(_timer); _timer = null } }
|
|
|
|
async function handle (req, res, method, path) {
|
|
try {
|
|
if (path === '/dispatch/legacy-sync/preview' && method === 'GET') return json(res, 200, await sync({ dryRun: true }))
|
|
if (path === '/dispatch/legacy-sync/run' && method === 'POST') return json(res, 200, await sync({ dryRun: false }))
|
|
if (path === '/dispatch/legacy-sync/reconcile' && method === 'GET') return json(res, 200, await reconcile())
|
|
if (path === '/dispatch/legacy-sync/close-resolved' && method === 'POST') return json(res, 200, await closeResolved())
|
|
if (path === '/dispatch/legacy-sync/ticket-thread' && method === 'GET') { const id = new URL(req.url, 'http://localhost').searchParams.get('id'); return json(res, 200, await ticketThread(id)) }
|
|
if (path === '/dispatch/legacy-sync/status' && method === 'GET') { // heartbeat pour Uptime-Kuma (keyword "stale":false)
|
|
const ageMin = _lastRun ? Math.round((Date.now() - Date.parse(_lastRun.at)) / 60000) : null
|
|
const max = (Number(process.env.LEGACY_DISPATCH_SYNC_MIN) || 15) * 3 // toléré = 3 ticks
|
|
return json(res, 200, { ok: true, enabled: /^(on|1|true)$/i.test(String(process.env.LEGACY_DISPATCH_SYNC || '')), last_sync: _lastRun, age_min: ageMin, stale: ageMin == null || ageMin > max })
|
|
}
|
|
return json(res, 404, { error: 'route inconnue' })
|
|
} catch (e) {
|
|
return json(res, 500, { error: String((e && e.message) || e) })
|
|
}
|
|
}
|
|
|
|
module.exports = { handle, sync, startSync, stopSync, fetchTargoTickets, coord, prio, startTime, jobType } // parseurs purs exposés pour les tests
|