'use strict'
// ── Collaboration légère sur un document ERPNext (ticket Issue, etc.) ───────
// Objectif : intervenir VITE (indice, note, mention d'un collègue) SANS toucher l'assignation (_assign intact).
// Stocké comme Comment ERPNext (visible aussi dans Desk) ; mention = notification courriel au collègue. UI = wizard épuré côté OPS.
const cfg = require('./config')
const { log, json, parseBody, lookupCustomersByPhone, lookupCustomersByEmail, readJsonFile } = require('./helpers')
const erp = require('./erp')
const { QUEUES } = require('./categories') // les 4 files (aussi des Issue Type valides) — repli de classification
const { resolveIssueType } = require('./issue-types') // category → Issue Type réel canonique (ou null) — voir lib/issue-types.js
// Mots « problème » (jamais un nom → toujours retirés) vs mots « rue » (retirés SEULEMENT en contexte adresse,
// pour ne pas amputer un nom comme « Saint-Pierre »).
const PROBLEM = /\b(probl[eè]me|panne|wi-?fi|internet|t[ée]l[ée]vision|t[ée]l[ée]phone|t[ée]l[ée]|lent[e]?|ralenti|coup[ée]+|d[ée]connect|bug|app|appartement)\b/gi
const STREET = /\b(rue|av|ave|avenue|boul|boulevard|ch|chemin|st|ste|saint|sainte|qc|no)\b/gi
// Normalise pour le fuzzy : minuscules + sans accents + sans séparateurs (« Louis-Paul » = « Louis-paul » = « LouisPaul »).
const normName = (s) => String(s || '').normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/[^a-z0-9]/g, '')
// Recherche client UNIFIÉE depuis un texte libre : téléphone (10 ch.), no civique (→ adresse via Service Location),
// et/ou nom (→ Customer). Insensible à la casse/accents (unaccent). Renvoie des candidats classés pour l'autosuggest.
async function searchCustomers (q) {
const s = String(q || '').trim()
if (s.length < 2) return []
const nums = s.match(/\d+/g) || []
const phone = nums.find(n => n.length >= 10)
const civic = nums.find(n => n.length >= 1 && n.length <= 6 && n !== phone)
let text = s.replace(/\d+/g, ' ').replace(PROBLEM, ' ')
if (civic) text = text.replace(STREET, ' ') // contexte adresse → on retire « rue/st/… » pour isoler le nom ; sinon on garde (« Saint-Pierre »)
text = text.replace(/[^\p{L}\s'-]/gu, ' ').replace(/\s+/g, ' ').trim()
const found = new Map() // name -> { matched, address? }
// 1) Téléphone (REST multi-champs)
if (phone) { try { for (const c of await lookupCustomersByPhone(phone, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'téléphone' }) } catch (e) { /* */ } }
// 1b) Courriel — la requête ressemble à une adresse e-mail
if (/.+@.+/.test(s)) { try { for (const c of await lookupCustomersByEmail(s, 6)) if (!found.has(c.name)) found.set(c.name, { matched: 'courriel' }) } catch (e) { /* */ } }
let p = null
try { p = require('./address-db').pool() } catch (e) { /* address-db indispo */ }
let addrResolved = false // adresse civique résolue → on NE mélange PAS les homonymes (fuzzy nom) : « 2338 rue X » = LE résident, pas les noms qui se ressemblent.
// 2) No civique → adresse (Service Location → client). Typo de dictée toléré : si rien, RQA (trigram) canonicalise la rue puis on ré-essaie.
if (p && civic && found.size < 12) {
const slByAddr = async (toks) => {
const cond = toks.length ? ' AND unaccent(lower(coalesce(sl.address_line,\'\'))) LIKE unaccent(lower($2))' : ''
const r = await p.query(
`SELECT DISTINCT sl.name, sl.customer, sl.address_line, sl.city FROM "tabService Location" sl
WHERE sl.customer IS NOT NULL AND sl.address_line ILIKE $1 ${cond} ORDER BY sl.address_line LIMIT 8`,
toks.length ? ['%' + civic + '%', '%' + toks.join('%') + '%'] : ['%' + civic + '%'])
return r.rows
}
try {
let street = text.split(/[\s-]+/).filter(w => w.length >= 3).slice(0, 2)
let rows = await slByAddr(street)
// Rue mal orthographiée / dictée (« Vinette » vs « Vinet ») → RQA fuzzy trouve la vraie rue → on ré-essaie. Seulement si la requête ressemble à une adresse (mot « rue/rang/… »).
if (!rows.length && /\b(rue|av|ave|avenue|boul|boulevard|ch|chemin|rang|mont|place|impasse|croissant|terrasse|c[oô]te)\b/i.test(s)) {
try { const canon = (await require('./address-db').searchRaw(s, 1))[0]; const rue = canon && (canon.rue || canon.odonyme_recompose_normal); if (rue) { const t = String(rue).split(/[\s-]+/).filter(w => w.length >= 3).slice(0, 2); if (t.length) rows = await slByAddr(t) } } catch (e) { /* RQA indispo */ }
}
for (const row of rows) if (row.customer && !found.has(row.customer)) found.set(row.customer, { matched: 'adresse', address: [row.address_line, row.city].filter(Boolean).join(', '), service_location: row.name }) // lie la BONNE adresse de service (pas la facturation)
if (rows.length) addrResolved = true // → on saute la recherche par NOM (homonymes) plus bas
} catch (e) { log('searchCustomers addr: ' + e.message) }
}
// 3) Nom (Customer) — CHAÎNE EXACTE D'ABORD, pg_trgm en REPLI.
// Passe 1 = le nom CONTIENT la chaîne littérale tapée (NORM LIKE '%qn%') : « louispaul » ne ramène QUE les
// vrais « Louis-Paul » (« louisgagnon » ne contient PAS « louispaul ») → zéro homonyme, le flou ne tourne même pas.
// Passe 2 = SEULEMENT si la passe 1 est vide (dictée / faute de frappe, ex. « bourdn ») → on élargit à la
// similarité trigramme word_similarity. Ainsi le bruit flou n'apparaît QUE quand l'exact ne donne rien.
if (p && found.size < 12 && !addrResolved) {
const qn = normName(text)
if (qn.length >= 3) {
const NORM = "regexp_replace(unaccent(lower(customer_name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
// Passe 1 — EXACT/littéral (pas de trigramme). exact = nom identique ; tous « strong » (contiennent la chaîne).
// Actifs d'abord, puis le plus court (le plus proche de l'exact : « Louis-Paul » avant « Louis-Paul Bergeron »).
rows = (await p.query(
`SELECT name, customer_name, (${NORM} = $1) AS exact, TRUE AS strong, NULL::float AS score FROM "tabCustomer"
WHERE ${NORM} LIKE '%'||$1||'%'
ORDER BY exact DESC, coalesce(disabled,0) ASC, length(customer_name) ASC LIMIT 12`, [qn])).rows
// Passe 2 — REPLI FLOU (pg_trgm) : uniquement si l'exact n'a RIEN trouvé. Seuil explicite (pas de GUC).
if (!rows.length) {
rows = (await p.query(
`SELECT name, customer_name, FALSE AS exact, FALSE AS strong, word_similarity($1, ${NORM}) AS score FROM "tabCustomer"
WHERE word_similarity($1, ${NORM}) > 0.4
ORDER BY coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 12`, [qn])).rows
}
} catch (e) {
// Repli si pg_trgm indisponible : chaîne littérale seule (tous « strong » par définition).
try { rows = (await p.query(`SELECT name, customer_name, (${NORM} = $1) AS exact, TRUE AS strong FROM "tabCustomer" WHERE ${NORM} LIKE '%'||$1||'%' ORDER BY exact DESC, coalesce(disabled,0) ASC, customer_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchCustomers name: ' + e2.message) }
}
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'nom', score: row.score != null ? Math.round(row.score * 100) / 100 : null, exact: !!row.exact, strong: !!row.strong })
}
}
// 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). Sur les champs legacy,
// MÊME stratégie : chaîne EXACTE d'abord, flou en repli. Le repli flou ne tourne QUE si aucun match FORT n'a
// déjà été trouvé (nom / téléphone / courriel / adresse) — sinon un « louispaul » résolu se ferait polluer par
// des mandataires « Louis X » au trigramme. try/catch : colonnes custom absentes → ignoré (pas de régression).
if (p && found.size < 12 && !addrResolved) {
const qn = normName(text)
if (qn.length >= 3) {
const NORMM = "regexp_replace(unaccent(lower(coalesce(mandataire,'') || ' ' || coalesce(contact_name_legacy,''))), '[^a-z0-9]', '', 'g')"
const hasStrong = [...found.values()].some(v => v.exact || v.strong || ['téléphone', 'courriel', 'adresse'].includes(v.matched))
try {
// Passe 1 — chaîne littérale dans mandataire/contact.
let rows = (await p.query(
`SELECT name, customer_name, TRUE AS strong, NULL::float AS score FROM "tabCustomer"
WHERE ${NORMM} LIKE '%'||$1||'%' ORDER BY coalesce(disabled,0) ASC, length(customer_name) ASC LIMIT 6`, [qn])).rows
// Passe 2 — flou (pg_trgm), uniquement si rien en littéral ET aucun match fort ailleurs.
if (!rows.length && !hasStrong) {
rows = (await p.query(
`SELECT name, customer_name, FALSE AS strong, word_similarity($1, ${NORMM}) AS score FROM "tabCustomer"
WHERE word_similarity($1, ${NORMM}) > 0.45 ORDER BY coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
}
for (const row of rows) if (!found.has(row.name)) found.set(row.name, { matched: 'mandataire', score: row.score != null ? Math.round(row.score * 100) / 100 : null, strong: !!row.strong })
} catch (e) { log('searchCustomers mandataire: ' + e.message) }
}
}
if (!found.size) return []
// Enrichissement contacts (1 requête) : courriel + téléphones → résumé + « SMS dispo ? » (mobile présent).
const names = [...found.keys()].slice(0, 12)
let rows = []
if (p) { try { rows = (await p.query('SELECT name, customer_name, email_id, mobile_no, cell_phone, tel_home, tel_office, territory, disabled FROM "tabCustomer" WHERE name = ANY($1)', [names])).rows } catch (e) { log('searchCustomers enrich: ' + e.message) } }
return names.map(n => {
const r = rows.find(x => x.name === n) || {}; const f = found.get(n)
const mobile = r.mobile_no || r.cell_phone || ''
return {
name: n, customer_name: r.customer_name || n, email: r.email_id || '',
mobile, phone: mobile || r.tel_home || r.tel_office || '', can_sms: !!mobile,
territory: r.territory || '', address: f.address || '', matched: f.matched, inactive: !!r.disabled,
service_location: f.service_location || null,
// Tiers de correspondance (désambiguïsation) : exact = nom identique ; strong = identifiant précis
// (téléphone/courriel/adresse) OU nom qui contient la recherche complète. Le flou trigramme seul → ni l'un ni l'autre.
exact: !!f.exact,
strong: !!f.strong || ['téléphone', 'courriel', 'adresse'].includes(f.matched),
}
})
}
// Recherche MEMBRES DE L'ÉQUIPE (System Users ERPNext sur nos domaines @targo.ca / @targointernet.com) — pour choisir
// VITE un collègue comme destinataire To/Cc, le taguer ou l'assigner. Renvoyés AVANT les clients : un employé cherche
// d'abord un collègue (« michel » → Michel Blais, sans devoir préciser « b »). FUZZY (typo-tolérant) comme les clients.
// On scope STRICTEMENT à user_type='System User' : tabUser contient AUSSI tous les clients (Website User) → sinon « michel » noyé.
async function searchTeam (q) {
const s = String(q || '').trim(); if (s.length < 2) return []
const qn = normName(s); if (qn.length < 2) return []
let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
if (!p) return []
const NORM = "regexp_replace(unaccent(lower(coalesce(full_name,'') || ' ' || name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
rows = (await p.query(
`SELECT name, full_name, word_similarity($1, ${NORM}) AS score FROM "tabUser"
WHERE enabled = 1 AND user_type = 'System User'
AND (lower(name) LIKE '%@targo.ca' OR lower(name) LIKE '%@targointernet.com')
AND (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.35)
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, score DESC NULLS LAST LIMIT 8`, [qn])).rows
} catch (e) {
try { rows = (await p.query(`SELECT name, full_name FROM "tabUser" WHERE enabled=1 AND user_type='System User' AND (lower(name) LIKE '%@targo.ca' OR lower(name) LIKE '%@targointernet.com') AND ${NORM} LIKE '%'||$1||'%' ORDER BY full_name LIMIT 8`, [qn])).rows } catch (e2) { log('searchTeam: ' + e2.message) }
}
return rows.map(r => ({ name: r.name, customer_name: r.full_name || r.name, email: r.name, matched: 'équipe', kind: 'équipe', can_sms: false }))
}
// Recherche TECHNICIEN par nom (fuzzy, comme les clients) → pour texter un tech par la voix (« texto au technicien Marc »).
async function searchTechnicians (q) {
let text = String(q || '').replace(/\b(technicien|technicienne|tech)\b/gi, ' ').replace(/\s+/g, ' ').trim()
const qn = normName(text); if (qn.length < 2) return []
let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
if (!p) return []
const NORM = "regexp_replace(unaccent(lower(full_name)), '[^a-z0-9]', '', 'g')"
let rows = []
try {
rows = (await p.query(`SELECT name, full_name, phone, word_similarity($1, ${NORM}) AS score FROM "tabDispatch Technician"
WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, score DESC NULLS LAST LIMIT 6`, [qn])).rows
} catch (e) { try { rows = (await p.query(`SELECT name, full_name, phone FROM "tabDispatch Technician" WHERE ${NORM} LIKE '%'||$1||'%' LIMIT 6`, [qn])).rows } catch (e2) { log('searchTechnicians: ' + e2.message) } }
return rows.map(r => { const ph = String(r.phone || '').replace(/\D/g, ''); return { name: r.name, customer_name: r.full_name, phone: ph, can_sms: ph.length >= 10, kind: 'technicien' } })
}
// Destinataire = clients ∪ techniciens (pour les SMS/courriels orchestrés). Techs d'abord (contexte « texto au tech »).
async function searchRecipients (q) {
const [techs, custs] = await Promise.all([searchTechnicians(q), searchCustomers(q)])
const out = []
for (const t of techs) out.push(t)
for (const c of custs) out.push({ ...c, kind: c.kind || 'client' })
return out.slice(0, 6)
}
// Liste les adresses de service d'un compte (un compte entreprise en a plusieurs) → pour choisir la bonne.
// Adresses de service d'un compte, TRIÉES : celles avec un service ACTIF d'abord, puis par
// service actif le plus RÉCENT (start_date), puis création. → la 1re ligne = l'adresse à pré-sélectionner.
async function serviceLocations (customer) {
if (!customer) return []
let p = null; try { p = require('./address-db').pool() } catch (e) { return [] }
if (!p) return []
try {
const r = await p.query(`
SELECT sl.name, sl.address_line, sl.city, sl.status,
MAX(CASE WHEN ss.status='Actif' THEN 1 ELSE 0 END) AS has_active,
MAX(ss.start_date) FILTER (WHERE ss.status='Actif') AS last_active
FROM "tabService Location" sl
LEFT JOIN "tabService Subscription" ss ON ss.service_location = sl.name AND ss.customer = sl.customer
WHERE sl.customer = $1
GROUP BY sl.name, sl.address_line, sl.city, sl.status, sl.creation
ORDER BY has_active DESC, last_active DESC NULLS LAST, sl.creation DESC
LIMIT 50`, [customer])
return r.rows.map(x => ({ name: x.name, address: [x.address_line, x.city].filter(Boolean).join(', '), status: x.status || '', active: !!Number(x.has_active) }))
} catch (e) { log('serviceLocations: ' + e.message); return [] }
}
// Adresse de service par DÉFAUT = la plus récente ACTIVE (1re ligne de serviceLocations).
async function defaultServiceLocation (customer) {
const locs = await serviceLocations(customer)
return locs.length ? locs[0].name : null
}
// ── STATUT DU SERVICE (lecture seule, pendant « lecture » du Do Stuff de F) ─────────────
// Qualité du signal optique GPON (Rx power, dBm) — mêmes seuils que useDeviceStatus.signalQuality.
function signalFrom (rx) {
if (rx == null || isNaN(rx)) return null
if (rx > -8) return 'excellent'; if (rx > -20) return 'bon'; if (rx > -25) return 'faible'; return 'critique'
}
// Combine TR-069 (canal de gestion) + OLT SNMP (fibre, AUTORITAIRE) → statut définitif.
// Miroir serveur de useDeviceStatus.combinedStatus : l'OLT prime, TR-069 = repli si pas de donnée OLT.
function combineDeviceStatus (summary, onu) {
const li = summary && summary.lastInform ? new Date(summary.lastInform).getTime() : null
const tr069 = li ? (Date.now() - li) < 15 * 60 * 1000 : null
const oltOnline = onu ? (onu.status === 'online') : null
let online, source, detail
if (oltOnline === true) { online = true; source = tr069 === true ? 'both' : 'olt'; detail = tr069 === true ? 'TR-069 + Fibre OK' : 'Fibre OK · TR-069 inactif' }
else if (oltOnline === false) { online = false; source = 'olt'; detail = tr069 === true ? 'Fibre coupée · TR-069 résiduel' : 'Fibre hors ligne' }
else if (tr069 === true) { online = true; source = 'tr069'; detail = 'TR-069 actif · fibre non vérifiée' }
else if (tr069 === false) { online = false; source = 'tr069'; detail = 'TR-069 inactif · fibre non vérifiée' }
else { online = null; source = 'unknown'; detail = 'Aucune donnée' }
const rx = onu && onu.rxPower != null ? Number(onu.rxPower) : (summary && summary.rxPower != null ? Number(summary.rxPower) : null)
return {
online, source, detail,
label: online === true ? 'En ligne' : online === false ? 'Hors ligne' : 'Inconnu',
rxPower: rx != null && !isNaN(rx) ? rx : null, signal: signalFrom(rx),
lastInform: (summary && summary.lastInform) || null,
minutesAgo: li ? Math.floor((Date.now() - li) / 60000) : null,
wifiClients: summary && summary.wifi ? (summary.wifi.totalClients || 0) : null,
hostsCount: summary && summary.hostsCount != null ? summary.hostsCount : null,
uptime: (summary && summary.uptime) || null,
ip: (summary && summary.ip) || null,
model: summary ? [summary.manufacturer, summary.model].filter(Boolean).join(' ').trim() : '',
offlineCause: (onu && onu.lastOfflineCause) || null,
oltName: (onu && onu.oltName) || null, oltPort: onu && onu.port != null ? onu.port : null,
}
}
// Statut « service + modem » par IDENTIFIANT LIBRE (adresse | nom | téléphone | courriel).
// Même paradigme que la création de ticket en langage naturel, mais en LECTURE : résout le compte →
// adresse de service active la plus récente → équipement(s) → statut TR-069 + OLT (fibre). Aucune dépendance à F.
async function serviceStatus ({ q, customer, location } = {}) {
const devices = require('./devices')
let olt = null; try { olt = require('./olt-snmp') } catch (e) { /* SNMP indispo */ }
// 1) Résolution du compte (courriel → lookupCustomersByEmail ; sinon nom/téléphone/adresse → searchCustomers)
let cust = customer || null, matches = []
if (!cust) {
const s = String(q || '').trim()
if (!s) return { ok: false, error: 'identifiant requis (adresse, nom, téléphone ou courriel)' }
if (/.+@.+\..+/.test(s)) {
try { matches = (await lookupCustomersByEmail(s, 6)).map(m => ({ name: m.name, customer_name: m.customer_name || m.name, email: m.email || s, phone: m.phone || '', matched: 'courriel' })) } catch (e) { matches = [] }
} else {
try { matches = await searchCustomers(s) } catch (e) { matches = [] }
}
matches = (matches || []).filter(m => m && m.name)
if (!matches.length) return { ok: true, found: false, matches: [], query: s }
if (matches.length > 1) return { ok: true, found: false, ambiguous: true, matches: matches.slice(0, 6), query: s }
cust = matches[0].name
}
// En-tête client (depuis le match, sinon fiche ERPNext si le compte est passé explicitement)
let head = matches[0] || null
if (!head && cust) { try { const c = await erp.get('Customer', cust); if (c) head = { name: cust, customer_name: c.customer_name || cust, email: c.email_id || '', phone: c.mobile_no || c.cell_phone || '' } } catch (e) { /* */ } }
// 2) Adresse de service (fournie, sinon active la plus récente)
const locs = await serviceLocations(cust)
const loc = location || (locs.length ? locs[0].name : null)
const locInfo = locs.find(l => l.name === loc) || (loc ? { name: loc, address: loc } : null)
// 3) Équipement(s) à cette adresse (repli : tout l'équipement du compte si rien à l'adresse)
const EQF = ['name', 'serial_number', 'mac_address', 'ip_address', 'brand', 'model', 'status', 'service_location', 'olt_ip']
let equip = []
try {
const f = [['customer', '=', cust]]; if (loc) f.push(['service_location', '=', loc])
equip = await erp.list('Service Equipment', { filters: f, fields: EQF, limit: 6 }) || []
if (!equip.length && loc) equip = await erp.list('Service Equipment', { filters: [['customer', '=', cust]], fields: EQF, limit: 6 }) || []
} catch (e) { log('serviceStatus equip ' + cust + ': ' + e.message) }
// 4) Statut par équipement (TR-069 via cache/ACS + OLT SNMP autoritaire)
const devs = []
for (const eq of equip.slice(0, 4)) {
const serial = eq.serial_number || null
let summary = null
if (serial) {
try { const c = devices.getCached(serial); summary = (c && c.summary && c.summary.lastInform) ? c.summary : await devices.fetchDeviceDetails(serial) } catch (e) { /* */ }
}
let onu = null; if (serial && olt) { try { onu = olt.getOnuBySerial(serial) } catch (e) { /* */ } }
devs.push({ serial, mac: eq.mac_address || null, olt: eq.olt_ip || null, brand: eq.brand || '', equipmentStatus: eq.status || '', service_location: eq.service_location || null, ...combineDeviceStatus(summary, onu), resolved: !!(summary || onu) })
}
// 5) Abonnement(s) service à l'adresse (moitié « service » : actif/suspendu + forfait)
let subs = []
try {
const f = [['customer', '=', cust]]; if (loc) f.push(['service_location', '=', loc])
subs = await erp.list('Service Subscription', { filters: f, fields: ['name', 'plan_name', 'status', 'start_date'], orderBy: 'start_date desc', limit: 8 }) || []
} catch (e) { /* doctype/champ absent → on ignore */ }
// Résumé global = le « meilleur » équipement (en ligne d'abord)
const best = devs.slice().sort((a, b) => (b.online === true ? 1 : 0) - (a.online === true ? 1 : 0))[0] || null
return {
ok: true, found: true,
customer: { name: cust, customer_name: (head && head.customer_name) || cust, email: (head && head.email) || '', phone: (head && head.phone) || '' },
location: locInfo, locations: locs,
subscriptions: subs.map(s => ({ name: s.name, plan: s.plan_name || '', status: s.status || '', start: s.start_date || null })),
devices: devs,
summary: best
? { online: best.online, label: best.label, detail: best.detail, source: best.source, signal: best.signal, rxPower: best.rxPower }
: { online: null, label: 'Aucun équipement', detail: equip.length ? 'équipement sans numéro de série' : 'aucun équipement lié à ce compte', source: 'none' },
}
}
const OPS_URL = () => (cfg.OPS_PUBLIC_URL || 'https://erp.gigafibre.ca/ops').replace(/\/$/, '')
const ERP_URL = () => (cfg.ERP_PUBLIC_URL || 'https://erp.gigafibre.ca').replace(/\/$/, '')
const shortName = (e) => String(e || '').split('@')[0]
// Activité récente (commentaires/notes) d'un document — pour afficher le fil dans le wizard.
async function activity (doctype, name) {
const doc = await erp.get(doctype, name)
let comments = []
try {
comments = await erp.list('Comment', {
filters: [['reference_doctype', '=', doctype], ['reference_name', '=', name], ['comment_type', 'in', ['Comment', 'Info']]],
fields: ['content', 'comment_email', 'comment_by', 'creation', 'comment_type'], orderBy: 'creation desc', limit: 25,
})
} catch (e) { log('collab activity ' + doctype + '/' + name + ': ' + e.message) }
return { doc: doc || null, comments }
}
// Ajoute un commentaire (note/indice) + notifie les collègues mentionnés. NE TOUCHE PAS _assign / l'assignation.
async function addComment (doctype, name, { text, mentions = [], agent } = {}) {
const content = String(text || '').trim()
if (!content) return { ok: false, error: 'texte requis' }
const ment = (Array.isArray(mentions) ? mentions : []).filter(e => /.+@.+\..+/.test(e))
const tag = ment.length ? ('
↪ ' + ment.map(shortName).join(', ') + '
') : ''
const r = await erp.create('Comment', {
comment_type: 'Comment', reference_doctype: doctype, reference_name: name,
content: tag + '' + content.replace(/') + '
' + (agent ? '— ' + shortName(agent) + ' (OPS)
' : ''),
comment_email: agent || 'ops@targo.ca', comment_by: agent || 'OPS',
})
if (!r.ok) return { ok: false, error: r.error }
// Notifier les mentionnés (courriel) — sans rien changer à l'assignation.
if (ment.length) {
const gmail = require('./gmail')
const subj = (doctype === 'Issue' ? 'Ticket ' : doctype + ' ') + name
// Deep link OPS (détail natif) — plus JAMAIS le desk ERPNext pour les tickets. Autres doctypes : desk en repli
// (rare ; à migrer quand un deep link générique existera).
const link = doctype === 'Issue'
? OPS_URL() + '/#/tickets?open=' + encodeURIComponent(name)
: ERP_URL() + '/app/' + doctype.toLowerCase().replace(/ /g, '-') + '/' + encodeURIComponent(name)
const body = `${shortName(agent) || 'Un collègue'} t'a mentionné sur ${subj} :\n\n${content}\n\nOuvrir : ${link}\n(OPS — collaboration, ceci ne change pas l'assignation du ticket.)`
for (const to of ment) { try { await gmail.sendMessage({ to, subject: `[OPS] mention — ${subj}`, body }) } catch (e) { log('collab notify ' + to + ': ' + e.message) } }
}
return { ok: true, mentioned: ment }
}
// Crée un ticket (ERPNext Issue) autonome — depuis le FAB / composer NL. customer optionnel ; queue = notifie l'équipe.
async function createTicket ({ title, category, priority, description, customer, customer_name, queue, status, due_date, service_location, agent } = {}) {
const t = String(title || '').trim()
if (!t) return { ok: false, error: 'titre requis' }
const cat = String(category || '').trim()
// category → Issue Type réel canonique (ou null si hors-doctype : « Autre »/« Commercial » de l'IA/dictée NL) — voir lib/issue-types.js.
const resolvedType = await resolveIssueType(cat)
const st = (status === 'On Hold' || status === 'pending' || status === 'En attente') ? 'On Hold' : 'Open' // « pending » = suspendu jusqu'à la date de rappel
const data = {
// [Cat] seulement si le type n'est PAS porté par issue_type (sinon redondant — classification = champ filtrable).
subject: ((cat && !resolvedType) ? '[' + cat + '] ' : '') + t.slice(0, 130), status: st, priority: priority || 'Medium',
description: (description ? String(description) + '\n\n' : '') + (agent ? '— créé par ' + shortName(agent) + ' (OPS)' : ''),
}
if (customer) data.customer = customer
// issue_type : type choisi (résolu) en priorité ; repli = la file de routage si c'en est une (les 4 files sont des Issue Type valides).
if (resolvedType) data.issue_type = resolvedType
else if (queue && QUEUES.includes(queue)) data.issue_type = queue
// Adresse de service : celle fournie, SINON l'adresse active la plus récente du compte (auto).
if (!service_location && customer) { try { service_location = await defaultServiceLocation(customer) } catch (e) {} }
if (service_location) data.custom_service_location = service_location // lie le ticket à la BONNE adresse de service
if (due_date && /^\d{4}-\d{2}-\d{2}/.test(due_date)) data.custom_reminder_date = due_date.slice(0, 10) // rappel / échéance
const r = await erp.create('Issue', data)
if (!r.ok) return { ok: false, error: r.error }
// Router vers l'équipe : notifier les membres de la file (ex. Supports) — comme un courriel entrant.
if (queue) {
const members = (readJsonFile('/app/data/queue_members.json', {})[queue] || []).filter(e => /.+@.+\..+/.test(e))
if (members.length) {
const link = OPS_URL() + '/#/tickets?open=' + encodeURIComponent(r.name) // détail natif OPS (fini le desk ERPNext)
const body = `Nouveau ticket pour l'équipe « ${queue} » :\n\n${data.subject}\n${customer_name ? 'Client : ' + customer_name + '\n' : ''}${description ? '\n' + description + '\n' : ''}\nOuvrir : ${link}\n— TARGO OPS`
try { await require('./outbox').enqueue({ to: members.join(','), subject: `[Ticket · ${queue}] ${data.subject}`.slice(0, 90), body }, { kind: 'ticket-notif', label: queue }) } catch (e) { log('createTicket notify ' + queue + ': ' + e.message) }
}
}
return { ok: true, name: r.name, subject: data.subject }
}
// ── ORCHESTRATEUR EN LANGAGE NATUREL / DICTÉE ──────────────────────────────
// « Crée un ticket de facture non payée pour Louispaul, envoie un texto à ce tech concernant telle adresse. »
// L'IA décompose en ACTIONS (sans inventer d'entités) ; le code RÉSOUT les clients (searchCustomers) ; on renvoie un
// PLAN à confirmer (les actions sortantes = SMS/ticket ne s'exécutent qu'après validation humaine). Puis /run exécute.
const ORCH_SYS = `Tu transformes la commande d'un agent (fournisseur Internet TARGO) en JSON d'ACTIONS. Réponds UNIQUEMENT par {"actions":[...]} (aucun texte autour).
Types autorisés :
- {"type":"create_ticket","customer_query":"","subject":"","category":"Support|Facturation|Installation|Télévision|Téléphonie|Commercial|Autre","priority":"Low|Medium|High|Urgent","status":"Open|On Hold","due_date":"YYYY-MM-DD ou null"}
- {"type":"send_sms","to_query":"","message":""}
- {"type":"send_email","to_query":"<...>","subject":"<...>","body":"<...>"}
- {"type":"note","text":""}
- {"type":"diagnose","customer_query":""} // QUESTION ou VÉRIFICATION d'état (lecture) : connexion, service, internet, modem, signal, « est-il en ligne ? », « a-t-il une panne ? »
Règles : status="On Hold" si on ATTEND quelque chose (paiement, pièce…). Ne résous PAS les entités, garde le terme dicté dans *_query. N'invente jamais de numéro, montant ni adresse. Déduis la catégorie par le sens (« facture »→Facturation, « wifi/panne »→Support, « installation »→Installation).
IMPORTANT — si la commande est une QUESTION ou une demande de VÉRIFICATION d'état (pas une action à exécuter), p.ex. « vérifie/vérifier la connexion|le service|internet de X », « est-ce que X est en ligne », « X a-t-il une panne », « statut de X » → utilise UNIQUEMENT {"type":"diagnose"} (JAMAIS create_ticket). Une vérification ne crée rien.`
async function orchestratePlan (text) {
if (!String(text || '').trim()) return { ok: false, error: 'commande vide' }
let out
try { out = await require('./ai').chat({ task: 'nl', maxTokens: 800, temperature: 0.1, messages: [{ role: 'system', content: ORCH_SYS }, { role: 'user', content: String(text) }] }) } catch (e) { return { ok: false, error: 'IA : ' + e.message } }
let parsed; try { parsed = JSON.parse((out.match(/\{[\s\S]*\}/) || ['{}'])[0]) } catch (e) { return { ok: false, error: 'réponse IA illisible' } }
const actions = Array.isArray(parsed.actions) ? parsed.actions.slice(0, 8) : []
for (const a of actions) {
// Intent LECTURE : on exécute le diagnostic tout de suite (sûr) — serviceStatus gère seul
// 1 client → diagnostic ; plusieurs → {ambiguous, matches} (l'agent précise l'adresse) ; aucun → introuvable.
if (a.type === 'diagnose') {
try { a.diagnostic = await serviceStatus({ q: a.customer_query || '' }) }
catch (e) { a.diagnostic = { ok: false, error: e.message } }
continue
}
const q = a.customer_query || a.to_query
if (!q) continue
if (/^\+?\d[\d\s().-]{6,}$/.test(String(q))) { a.phone = String(q).replace(/\D/g, ''); continue } // numéro brut dicté
try {
// ticket = pour un CLIENT ; sms/courriel = destinataire CLIENT ∪ TECHNICIEN.
const m = (a.type === 'create_ticket') ? await searchCustomers(q) : await searchRecipients(q)
a.candidates = m.slice(0, 4); a.resolved = m[0] || null
} catch (e) { a.candidates = []; a.resolved = null }
}
return { ok: true, actions, transcript: String(text) }
}
async function orchestrateRun (actions, agent) {
const conv = require('./conversation')
const results = []
for (const a of (Array.isArray(actions) ? actions : [])) {
try {
if (a.type === 'create_ticket') {
const r = await createTicket({ title: a.subject || 'Demande', category: a.category, priority: a.priority, status: a.status, due_date: a.due_date, customer: a.resolved && a.resolved.name, customer_name: a.resolved && a.resolved.customer_name, service_location: a.service_location || (a.resolved && a.resolved.service_location), queue: a.queue, agent })
results.push({ type: 'create_ticket', ok: r.ok, name: r.name, label: r.subject, error: r.error })
} else if (a.type === 'send_sms') {
const phone = a.phone || (a.resolved && a.resolved.phone)
if (!phone) { results.push({ type: 'send_sms', ok: false, error: 'destinataire sans numéro' }); continue }
const r = await conv.sendSms({ phone, customer: a.resolved && a.resolved.name, customerName: a.resolved && a.resolved.customer_name, message: a.message })
results.push({ type: 'send_sms', ok: r.ok, to: (a.resolved && a.resolved.customer_name) || phone, via: r.via, error: r.error })
} else if (a.type === 'send_email') {
const to = (a.resolved && a.resolved.email) || a.to
if (!to) { results.push({ type: 'send_email', ok: false, error: 'destinataire sans courriel' }); continue }
const r = await conv.sendNewEmail({ to, subject: a.subject, body: a.body, customer: a.resolved && a.resolved.name, customerName: a.resolved && a.resolved.customer_name })
results.push({ type: 'send_email', ok: r.ok, to, error: r.error })
} else if (a.type === 'note') {
results.push({ type: 'note', ok: true, text: a.text })
}
} catch (e) { results.push({ type: a.type, ok: false, error: e.message }) }
}
return { ok: true, results }
}
// ── Rapport : comptes avec TOTAL RÉCURRENT MENSUEL NÉGATIF ──────────────────
// Un abonnement récurrent ne peut pas être négatif (rabais > frais). F est autoritaire et
// correct ; ce rapport débusque la DÉRIVE côté ERPNext/OPS (rabais ajoutés à la main / artefacts
// d'import). Scan des Service Subscription ACTIF mensuelles, agrégées par (client, adresse).
// Calcul en arrière-plan + cache 1 h (la requête HTTP ne bloque pas ; la page sonde jusqu'à prêt).
let _negBill = { ts: 0, data: null, computing: false, error: null }
async function computeNegativeBilling () {
const groups = new Map() // clé client|adresse → { customer, service_location, total, charges, rebate, rebates[] }
const custTotals = new Map() // client → total mensuel ACTIF toutes adresses (pour distinguer un vrai négatif d'un artefact de regroupement par adresse)
// « Actif/Inactif » du client = statut de compte GLOBAL F (account.status), récupéré après le scan (cf. fAccountStatuses) — autorité.
const TODAY = new Date().toISOString().slice(0, 10)
let start = 0, scanned = 0; const PAGE = 2000
for (let i = 0; i < 60; i++) { // borne dure 120k
const rows = await erp.list('Service Subscription', {
filters: [['status', '=', 'Actif'], ['billing_cycle', '!=', 'Annuel']],
fields: ['name', 'customer', 'service_location', 'monthly_price', 'plan_name', 'product_sku', 'legacy_service_id', 'end_date'],
limit: PAGE, start,
}) || []
if (!rows.length) break
for (const r of rows) {
const price = Number(r.monthly_price || 0)
const cust = r.customer || '?'
const key = cust + '|' + (r.service_location || '')
const legacy = Number(r.legacy_service_id || 0)
let g = groups.get(key)
if (!g) { g = { customer: cust, service_location: r.service_location || '', total: 0, charges: 0, rebate: 0, rebates: [], manualRebates: 0, expiredActive: 0 }; groups.set(key, g) }
g.total += price
custTotals.set(cust, (custTotals.get(cust) || 0) + price)
// signal de fin de service non respectée (date de fin passée mais encore Actif)
if (r.end_date && r.end_date < TODAY) g.expiredActive++
if (price < 0) {
g.rebate += price
if (legacy <= 0) g.manualRebates++ // rabais SANS lien F = ajouté dans ERPNext
g.rebates.push({ name: r.name, sku: r.product_sku || '', plan: r.plan_name || '', price, legacy, source: legacy > 0 ? ('F #' + legacy) : 'ajouté OPS', end_date: r.end_date || null })
} else g.charges += price
}
scanned += rows.length
if (rows.length < PAGE) break
start += PAGE
}
const neg = [...groups.values()].filter(g => g.total < -0.005).sort((a, b) => a.total - b.total)
// Pour chaque compte négatif : examiner TOUS les abos (actif+inactif) → une CHARGE suspendue/annulée
// qui rendrait le total positif = désync de statut F→ERPNext (cause réelle vue sur Schink : FTTH500 Suspendu
// dans ERPNext mais actif dans F). Peu de comptes (~quelques dizaines) → fetch par compte économique.
const allByCust = new Map()
for (const g of neg) {
if (!allByCust.has(g.customer)) {
try { allByCust.set(g.customer, await erp.list('Service Subscription', { filters: [['customer', '=', g.customer]], fields: ['name', 'status', 'monthly_price', 'plan_name', 'service_location', 'legacy_service_id', 'billing_cycle'], limit: 100 }) || []) } catch (e) { allByCust.set(g.customer, []) }
}
const inact = (allByCust.get(g.customer) || []).filter(s => (s.service_location || '') === g.service_location && s.billing_cycle !== 'Annuel' && Number(s.monthly_price || 0) > 0 && s.status !== 'Actif')
g.inactiveChargeSum = inact.reduce((s, x) => s + Number(x.monthly_price || 0), 0)
g.inactiveCharges = inact.map(s => ({ plan: s.plan_name || '', price: Number(s.monthly_price || 0), status: s.status || '', legacy: Number(s.legacy_service_id || 0) }))
}
const causeOf = (g, ct) => {
if (ct >= -0.005) return 'artifact' // rabais/frais sur adresses ≠ → compte OK
if (g.manualRebates > 0) return 'manual' // rabais ajouté hors F
if (g.total + (g.inactiveChargeSum || 0) >= -0.005) return 'suspended_charge' // une charge suspendue/annulée explique le négatif
if (g.expiredActive > 0) return 'expired'
return 'stacked_rebates' // vrais rabais F qui dépassent (à vérifier vs F)
}
// Noms clients + ID de compte F (batch 'in' par 100)
const ids = [...new Set(neg.map(g => g.customer).filter(Boolean))]
const names = {}, acctIdOf = {}
for (let i = 0; i < ids.length; i += 100) {
try { const cs = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: ['name', 'customer_name', 'legacy_account_id'], limit: 100 }) || []; for (const c of cs) { names[c.name] = c.customer_name; acctIdOf[c.name] = Number(c.legacy_account_id || 0) } } catch (e) { /* nom optionnel */ }
}
// Statut de compte GLOBAL F (account.status : 1=Actif, 2=Suspendu, 3/4/5=Résilié) — l'autorité (≠ heuristique forfait, ≠ flag disabled).
let acctStatus = {}; const ls = require('./legacy-sync')
try { acctStatus = await ls.fAccountStatuses(Object.values(acctIdOf)) } catch (e) { log('negative-billing fAccountStatuses: ' + e.message) }
const LABEL = ls.F_ACCT_STATUS_LABEL || {}
const round = n => Math.round(n * 100) / 100
return {
count: neg.length, scanned, generated: Date.now(),
rows: neg.slice(0, 2000).map(g => { const ct = round(custTotals.get(g.customer) || 0); const fst = acctStatus[acctIdOf[g.customer]]; return ({ customer: g.customer, customer_name: names[g.customer] || g.customer, service_location: g.service_location, total: round(g.total), charges: round(g.charges), rebate: round(g.rebate), customer_total: ct, account_status: fst != null ? fst : null, account_status_label: LABEL[fst] || (fst != null ? 'Inconnu' : '—'), customer_active: fst === 1, manualRebates: g.manualRebates, expiredActive: g.expiredActive, inactiveChargeSum: round(g.inactiveChargeSum || 0), inactiveCharges: g.inactiveCharges || [], cause: causeOf(g, ct), rebates: g.rebates }) }),
}
}
async function negativeBilling (refresh) {
const fresh = (Date.now() - _negBill.ts) < 3600000
if (!refresh && _negBill.data && fresh) return { ready: true, cached: true, ..._negBill.data }
if (_negBill.computing) return { ready: false, computing: true }
if (!refresh && _negBill.error && fresh) return { ready: false, error: _negBill.error }
_negBill.computing = true
computeNegativeBilling()
.then(d => { _negBill = { ts: Date.now(), data: d, computing: false, error: null }; log(`negative-billing: ${d.count} comptes négatifs / ${d.scanned} abos scannés`) })
.catch(e => { _negBill = { ts: Date.now(), data: null, computing: false, error: e.message }; log('negative-billing ERROR: ' + e.message) })
return { ready: false, computing: true, started: true }
}
// ── Rapport : COMPTES RÉSILIÉS qui gardent des SERVICES ACTIFS (à nettoyer au niveau service) ──
// F ne désactive pas les services récurrents à la résiliation → charges + crédits restent status=1.
// Liste de ménage (les employés à internet fourni sont des exceptions à garder). Calcul en arrière-plan + cache 1 h.
let _termActive = { ts: 0, data: null, computing: false, error: null }
async function computeTerminatedActive () {
const ls = require('./legacy-sync')
const resilieIds = await ls.fResiliatedAccountIds()
if (!resilieIds.length) return { count: 0, services: 0, generated: Date.now(), rows: [] }
const pgp = require('./address-db').pool()
const custAcct = new Map() // customer ERPNext → legacy_account_id (résilié)
if (pgp) {
for (let i = 0; i < resilieIds.length; i += 5000) {
try { const r = await pgp.query('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id = ANY($1)', [resilieIds.slice(i, i + 5000)]); for (const x of r.rows) custAcct.set(x.name, Number(x.legacy_account_id)) } catch (e) { log('terminated-active PG: ' + e.message) }
}
}
if (!custAcct.size) return { count: 0, services: 0, generated: Date.now(), rows: [] }
const byCust = new Map()
let start = 0, scanned = 0; const PAGE = 2000
for (let i = 0; i < 60; i++) {
const rows = await erp.list('Service Subscription', { filters: [['status', '=', 'Actif'], ['billing_cycle', '!=', 'Annuel']], fields: ['name', 'customer', 'service_location', 'monthly_price', 'plan_name', 'product_sku', 'legacy_service_id'], limit: PAGE, start }) || []
if (!rows.length) break
for (const r of rows) {
if (!custAcct.has(r.customer)) continue
let g = byCust.get(r.customer); if (!g) { g = { customer: r.customer, total: 0, services: [] }; byCust.set(r.customer, g) }
const price = Number(r.monthly_price || 0)
g.total += price
g.services.push({ legacy: Number(r.legacy_service_id || 0), sku: r.product_sku || '', plan: r.plan_name || '', price, location: r.service_location || '' })
}
scanned += rows.length
if (rows.length < PAGE) break
start += PAGE
}
const ids = [...byCust.keys()]; const names = {}
for (let i = 0; i < ids.length; i += 100) { try { const cs = await erp.list('Customer', { filters: [['name', 'in', ids.slice(i, i + 100)]], fields: ['name', 'customer_name'], limit: 100 }) || []; for (const c of cs) names[c.name] = c.customer_name } catch (e) { /* */ } }
const round = n => Math.round(n * 100) / 100
const rows = [...byCust.values()].map(g => ({
customer: g.customer, customer_name: names[g.customer] || g.customer, account_id: custAcct.get(g.customer),
active_services: g.services.length,
charges: round(g.services.filter(s => s.price > 0).reduce((a, s) => a + s.price, 0)),
credits: round(g.services.filter(s => s.price < 0).reduce((a, s) => a + s.price, 0)),
total: round(g.total),
services: g.services.map(s => ({ ...s, price: round(s.price) })),
})).sort((a, b) => b.active_services - a.active_services || b.total - a.total)
return { count: rows.length, services: rows.reduce((a, r) => a + r.active_services, 0), scanned, generated: Date.now(), rows: rows.slice(0, 3000) }
}
async function terminatedActive (refresh) {
const fresh = (Date.now() - _termActive.ts) < 3600000
if (!refresh && _termActive.data && fresh) return { ready: true, cached: true, ..._termActive.data }
if (_termActive.computing) return { ready: false, computing: true }
if (!refresh && _termActive.error && fresh) return { ready: false, error: _termActive.error }
_termActive.computing = true
computeTerminatedActive()
.then(d => { _termActive = { ts: Date.now(), data: d, computing: false, error: null }; log(`terminated-active: ${d.count} comptes résiliés / ${d.services} services actifs`) })
.catch(e => { _termActive = { ts: Date.now(), data: null, computing: false, error: e.message }; log('terminated-active ERROR: ' + e.message) })
return { ready: false, computing: true, started: true }
}
async function handle (req, res, method, p, url) {
if (!_macSyncStarted) { _macSyncStarted = true; try { startWirelessMacSync() } catch (e) { /* */ } } // planificateur démarré au 1er requête (pas au require → pas de timer en test)
// POST /collab/orchestrate {text} → PLAN d'actions (ne s'exécute pas). POST /collab/orchestrate/run {actions} → exécute (confirmé).
if (p === '/collab/orchestrate' && method === 'POST') {
const b = await parseBody(req); return json(res, 200, await orchestratePlan(b.text || ''))
}
if (p === '/collab/orchestrate/run' && method === 'POST') {
const b = await parseBody(req); return json(res, 200, await orchestrateRun(b.actions, req.headers['x-authentik-email'] || b.agent || ''))
}
// GET /collab/customer-search?q=&team=1 — autosuggest contact par texte libre (nom / courriel / téléphone / adresse).
// team=1 → inclut les MEMBRES DE L'ÉQUIPE en PREMIER (destinataires To/Cc, tag collègue) ; sinon clients seuls (recherche globale → fiche).
if (p === '/collab/customer-search' && method === 'GET') {
const q = url.searchParams.get('q') || ''
const wantTeam = url.searchParams.get('team') === '1'
const [team, custs] = await Promise.all([wantTeam ? searchTeam(q) : Promise.resolve([]), searchCustomers(q)])
const seen = new Set(team.map(t => (t.email || '').toLowerCase()))
const matches = [...team, ...custs.filter(c => !(c.email && seen.has(c.email.toLowerCase())))]
return json(res, 200, { matches })
}
// GET /collab/ratings — évaluations clients (basses prioritaires) pour la vue Ops « satisfaction »
if (p === '/collab/ratings' && method === 'GET') {
return json(res, 200, require('./rating').listRatings({ limit: Number(url.searchParams.get('limit')) || 150, customer: url.searchParams.get('customer') || '' }))
}
// DELETE /collab/ratings/ — supprimer une évaluation (avis de test)
const mRatingDel = p.match(/^\/collab\/ratings\/([\w-]+)$/)
if (mRatingDel && method === 'DELETE') {
return json(res, 200, { ok: require('./rating').deleteRating(mRatingDel[1]) })
}
// GET /collab/rating-invite-draft?customer=&channel=&kind=invite|feedback — BROUILLON pré-rempli (coords + message, langue du client) pour la fenêtre Compose.
// kind=invite (défaut) : invitation à évaluer (courriel = {{rating}} ; SMS = lien /rate/start tokenisé). kind=feedback : « écrire au client » (suite à un avis, ex. mécontent ; aucun lien).
if (p === '/collab/rating-invite-draft' && method === 'GET') {
const customer = url.searchParams.get('customer') || ''
const channel = url.searchParams.get('channel') === 'sms' ? 'sms' : 'email'
const kind = url.searchParams.get('kind') === 'feedback' ? 'feedback' : 'invite'
let email = url.searchParams.get('email') || ''; let phone = url.searchParams.get('phone') || ''; let name = url.searchParams.get('name') || ''; let lang = url.searchParams.get('lang') || ''
if (customer) { try { const c = await erp.get('Customer', customer); if (c) { email = email || c.email_id || c.email_billing || ''; phone = phone || c.cell_phone || c.mobile_no || ''; lang = lang || c.language || ''; name = name || c.customer_name || '' } } catch (e) { /* */ } }
if (!customer && !email && !phone) return json(res, 400, { error: 'customer ou coordonnées requis' })
const rating = require('./rating')
if (channel === 'sms') {
if (!phone) return json(res, 200, { ok: false, error: 'Aucun numéro mobile pour ce client' })
const text = kind === 'feedback' ? rating.feedbackSms(name, lang) : rating.inviteSmsText(rating.newRatingToken({ customer, email, name, lang }), lang)
return json(res, 200, { ok: true, prefill: { channel: 'sms', phone, customer, customerName: name, text } })
}
if (!email) return json(res, 200, { ok: false, error: 'Aucun courriel pour ce client' })
const subject = kind === 'feedback' ? rating.feedbackSubject(lang) : rating.inviteSubject(lang)
const prefill = { channel: 'email', to: email, customer, customerName: name, subject }
if (kind === 'feedback') prefill.html = rating.feedbackHtml(name, lang) // texte simple → éditable dans l'éditeur
else prefill.advHtml = rating.inviteEmailMarkerHtml(lang, name) // courriel MIS EN PAGE → envoyé tel quel (composeAdvHtml)
return json(res, 200, { ok: true, prefill })
}
// POST /collab/send-rating-invite {customer|email|phone, channel} — renvoie l'invitation d'évaluation (courriel/SMS) DANS LA LANGUE DU CLIENT.
if (p === '/collab/send-rating-invite' && method === 'POST') {
const b = await parseBody(req)
const channel = b.channel === 'sms' ? 'sms' : 'email'
let email = String(b.email || ''); let phone = String(b.phone || ''); let name = String(b.name || ''); let lang = String(b.lang || '')
const customer = String(b.customer || ''); const conv = String(b.conv || '')
if (customer) { // résout courriel / mobile / langue depuis la fiche
try { const c = await erp.get('Customer', customer); if (c) { email = email || c.email_id || c.email_billing || ''; phone = phone || c.cell_phone || c.mobile_no || ''; lang = lang || c.language || ''; name = name || c.customer_name || '' } } catch (e) { /* */ }
}
const rating = require('./rating')
const token = rating.newRatingToken({ customer, conv, email, name, lang })
if (channel === 'sms') {
if (!phone) return json(res, 400, { error: 'Aucun numéro mobile pour ce client' })
try { const { sendSmsInternal } = require('./twilio'); const ok = await sendSmsInternal(phone, rating.inviteSmsText(token, lang), customer || null); return json(res, ok ? 200 : 502, { ok: !!ok, channel: 'sms', to: phone }) } catch (e) { return json(res, 500, { error: e.message }) }
}
if (!email) return json(res, 400, { error: 'Aucun courriel pour ce client' })
try { const r = await require('./conversation').sendNewEmail({ to: email, subject: rating.inviteSubject(lang), html: rating.inviteEmailHtml(token, lang, name), customer, customerName: name, agentEmail: req.headers['x-authentik-email'] || '' }); return json(res, r && r.ok ? 200 : 400, { ok: !!(r && r.ok), channel: 'email', to: email }) } catch (e) { return json(res, 500, { error: e.message }) }
}
// POST /collab/suggest-account — croise les signaux d'un courriel pour proposer le BON compte même quand
// l'expéditeur ≠ le titulaire (ex. mandataire). IA extrait adresse/nom/tél du CORPS, puis recoupe + classe.
if (p === '/collab/suggest-account' && method === 'POST') {
const b = await parseBody(req)
const senderEmail = String(b.email || '').trim()
const senderName = String(b.name || '').trim()
const text = String(b.text || '').slice(0, 4000)
let ex = {}
if (text) {
try {
const sys = 'Tu extrais les identifiants de compte qu\'un client mentionne dans un courriel à un fournisseur Internet. Le TITULAIRE du compte peut différer de l\'expéditeur (ex. un mandataire écrit pour le titulaire). Renvoie UNIQUEMENT du JSON : {"address":"adresse civique complète si mentionnée, sinon vide","names":["noms complets de personnes mentionnés"],"invoice_number":"","phone":""}. N\'invente rien ; vide si absent.'
const out = await require('./ai').chat({ task: 'default', maxTokens: 300, temperature: 0, reasoningEffort: 'none', messages: [{ role: 'system', content: sys }, { role: 'user', content: text }] })
ex = JSON.parse((String(out || '').match(/\{[\s\S]*\}/) || ['{}'])[0]) || {}
} catch (e) { ex = {} }
}
// Signaux pondérés : l'adresse du corps est la plus discriminante ; corroboration par un nom = forte confiance.
const queries = []
if (ex.address) queries.push({ q: ex.address, signal: 'adresse du courriel', w: 5 })
for (const n of (Array.isArray(ex.names) ? ex.names : [])) if (n && String(n).trim().length > 2) queries.push({ q: String(n), signal: 'nom mentionné', w: 3 })
if (ex.phone) queries.push({ q: ex.phone, signal: 'téléphone du courriel', w: 3 })
if (senderName) queries.push({ q: senderName, signal: 'expéditeur', w: 2 })
if (senderEmail) queries.push({ q: senderEmail, signal: 'courriel expéditeur', w: 2 })
const acc = new Map()
for (const { q, signal, w } of queries) {
let matches = []
try { matches = await searchCustomers(q) } catch (e) { matches = [] }
for (const m of matches.slice(0, 5)) {
const cur = acc.get(m.name) || { ...m, score: 0, signals: [] }
cur.score += w
if (!cur.signals.includes(signal)) cur.signals.push(signal)
acc.set(m.name, cur)
}
}
const suggestions = [...acc.values()].sort((a, b) => b.score - a.score).slice(0, 6)
return json(res, 200, { suggestions, extracted: ex })
}
// GET /collab/service-locations?customer= — adresses de service d'un compte (multi-adresses)
if (p === '/collab/service-locations' && method === 'GET') {
return json(res, 200, { locations: await serviceLocations(url.searchParams.get('customer') || '') })
}
// GET /collab/service-status?q=[&customer=&location=] — statut service+modem (lecture)
if (p === '/collab/service-status' && method === 'GET') {
return json(res, 200, await serviceStatus({ q: url.searchParams.get('q') || '', customer: url.searchParams.get('customer') || '', location: url.searchParams.get('location') || '' }))
}
// GET /collab/dostuff?serial=&olt= — statut LIVE du modem via le webhook n8n que F utilise (LENT ~25-30 s ; tech=3)
if (p === '/collab/dostuff' && method === 'GET') {
return json(res, 200, await dostuffStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', olt: url.searchParams.get('olt') || '' }))
}
// GET /collab/airos-signal?serial=[&ip=&port=&user=] — signal LIVE d'un CPE SANS-FIL (airOS/Cambium) via webhook n8n
// (le hub ne joint PAS les CPE directement → pont n8n, comme la fibre TP-Link). 404 = webhook pas encore créé.
if (p === '/collab/airos-signal' && method === 'GET') {
return json(res, 200, await airosSignal({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', ip: url.searchParams.get('ip') || '', port: url.searchParams.get('port') || '', user: url.searchParams.get('user') || '' }))
}
// GET /collab/airos-status?serial=|ip= — état RAPIDE (RADIUS seul, pas de status.cgi) pour le chargement de page.
if (p === '/collab/airos-status' && method === 'GET') {
return json(res, 200, await airosStatus({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', ip: url.searchParams.get('ip') || '' }))
}
// GET /collab/airos-leases?serial=|ip= — baux DHCP (appareils derrière le CPE sans-fil), via SSH sur le pont F.
if (p === '/collab/airos-leases' && method === 'GET') {
return json(res, 200, await airosLeases({ sn: url.searchParams.get('serial') || url.searchParams.get('sn') || '', ip: url.searchParams.get('ip') || '' }))
}
// POST /collab/airos-mac-sync — réconciliation MAC park-wide à la demande (le planificateur 03h l'appelle aussi).
if (p === '/collab/airos-mac-sync' && method === 'POST') {
return json(res, 200, await syncWirelessMacs())
}
// GET /collab/fibre-live?equipment=EQP-xxx[&serial=&olt=] — reconcilie l'ONU depuis F fibre (serial courant) PUIS dostuff.
if (p === '/collab/fibre-live' && method === 'GET') {
return json(res, 200, await fibreLive({ equipment: url.searchParams.get('equipment') || '', serial: url.searchParams.get('serial') || '', olt: url.searchParams.get('olt') || '' }))
}
// POST /collab/fibre-sync — reconcile ONU fibre park-wide à la demande (le planificateur 03h l'appelle aussi).
if (p === '/collab/fibre-sync' && method === 'POST') {
return json(res, 200, await syncFibreOnus())
}
// GET /collab/negative-billing[?refresh=1] — comptes dont le total récurrent mensuel ACTIF est < 0 (rabais > frais)
if (p === '/collab/negative-billing' && method === 'GET') {
return json(res, 200, await negativeBilling(url.searchParams.get('refresh') === '1'))
}
// GET /collab/terminated-active[?refresh=1] — comptes RÉSILIÉS (F status 3/4/5) gardant des services ACTIFS (ménage)
if (p === '/collab/terminated-active' && method === 'GET') {
return json(res, 200, await terminatedActive(url.searchParams.get('refresh') === '1'))
}
// GET /collab/wan-rate?serial=[&olt=&slot=&port=&ontid=] — compteurs d'octets WAN de l'ONU (Raisecom/tech-2 seulement),
// tels que F les lit dans raisecom_rcmg_bandwith.php. Le CLIENT échantillonne 2× et calcule le débit (delta/sec*8).
if (p === '/collab/wan-rate' && method === 'GET') {
let olt = null; try { olt = require('./olt-snmp') } catch (e) { return json(res, 200, { available: false, reason: 'snmp_unavailable' }) }
const serial = url.searchParams.get('serial') || url.searchParams.get('sn') || ''
const opts = {}
if (url.searchParams.get('olt')) opts.oltIp = url.searchParams.get('olt')
if (url.searchParams.get('slot') != null && url.searchParams.get('slot') !== '') opts.slot = url.searchParams.get('slot')
if (url.searchParams.get('port') != null && url.searchParams.get('port') !== '') opts.port = url.searchParams.get('port')
if (url.searchParams.get('ontid') != null && url.searchParams.get('ontid') !== '') opts.ontId = url.searchParams.get('ontid')
if (url.searchParams.get('type')) opts.type = url.searchParams.get('type')
if (!serial && !opts.oltIp) return json(res, 400, { error: 'serial ou olt requis' })
try { return json(res, 200, await olt.getOnuBandwidth(serial, opts)) }
catch (e) { return json(res, 200, { available: false, reason: 'error', error: e.message }) }
}
// GET /collab/diag-link?customer=C-XXX[&name=] — lien de diagnostic libre-service (token signé 7 j) à envoyer au client
if (p === '/collab/diag-link' && method === 'GET') {
const customer = url.searchParams.get('customer') || ''
if (!customer) return json(res, 400, { error: 'customer requis' })
let name = url.searchParams.get('name') || ''
if (!name) { try { const c = await erp.get('Customer', customer); name = (c && c.customer_name) || '' } catch (e) { /* */ } }
const token = require('./magic-link').generateCustomerToken(customer, name, '', 24 * 7)
const base = (cfg.CLIENT_PUBLIC_URL || 'https://app.gigafibre.ca').replace(/\/$/, '')
return json(res, 200, { ok: true, link: base + '/diag/' + token, customer, name })
}
// ── Bibliothèque d'images de pièces jointes (équipements fréquents, réutilisables — mêmes images qu'une FAQ) ──
// Manifeste léger /app/data/attach_presets.json ; les octets vivent dans le store d'actifs (content-hash, partagé).
if (p === '/collab/attach-presets' && method === 'GET') {
const list = (readJsonFile('/app/data/attach_presets.json') || []).map(x => ({ ...x, url: require('./campaigns').uploadUrl(x.asset, req) }))
return json(res, 200, { presets: list })
}
if (p === '/collab/attach-presets' && method === 'POST') {
const b = await parseBody(req)
const campaigns = require('./campaigns')
const dec = campaigns.decodeDataUrl(b && b.data)
if (!dec || dec.error) return json(res, 400, { error: dec && dec.error === 'too_large' ? 'image trop volumineuse' : 'image invalide' })
const asset = campaigns.persistUpload(dec.buffer, dec.ext)
const id = 'ap_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 7)
const fs = require('fs'); const PRESETS = '/app/data/attach_presets.json'
const list = readJsonFile(PRESETS) || []
const preset = { id, name: String((b && b.name) || 'Image').slice(0, 80), category: String((b && b.category) || 'Équipement').slice(0, 40), asset, mime: dec.mime }
list.push(preset)
try { fs.writeFileSync(PRESETS, JSON.stringify(list, null, 2)) } catch (e) { return json(res, 500, { error: 'écriture manifeste' }) }
return json(res, 200, { ok: true, preset: { ...preset, url: campaigns.uploadUrl(asset, req) } })
}
const mPreset = p.match(/^\/collab\/attach-presets\/([\w-]+)$/)
if (mPreset && method === 'DELETE') {
const fs = require('fs'); const PRESETS = '/app/data/attach_presets.json'
const list = (readJsonFile(PRESETS) || []).filter(x => x.id !== mPreset[1]) // retiré du manifeste ; l'actif content-hash reste (potentiellement partagé)
try { fs.writeFileSync(PRESETS, JSON.stringify(list, null, 2)) } catch (e) { return json(res, 500, { error: 'écriture manifeste' }) }
return json(res, 200, { ok: true })
}
// POST /collab/ticket {title, category, priority, description, customer, customer_name, queue} — créer un ticket autonome
if (p === '/collab/ticket' && method === 'POST') {
const b = await parseBody(req)
const r = await createTicket({ ...b, agent: req.headers['x-authentik-email'] || b.agent || '' })
return json(res, r.ok ? 200 : 400, r)
}
// GET /collab/activity?doctype=Issue&name=ISS-0001
if (p === '/collab/activity' && method === 'GET') {
const doctype = url.searchParams.get('doctype'); const name = url.searchParams.get('name')
if (!doctype || !name) return json(res, 400, { error: 'doctype + name requis' })
return json(res, 200, await activity(doctype, name))
}
// POST /collab/comment {doctype, name, text, mentions}
if (p === '/collab/comment' && method === 'POST') {
const b = await parseBody(req)
if (!b.doctype || !b.name) return json(res, 400, { error: 'doctype + name requis' })
const agent = req.headers['x-authentik-email'] || b.agent || ''
const r = await addComment(b.doctype, b.name, { text: b.text, mentions: b.mentions, agent })
return json(res, r.ok ? 200 : 400, r)
}
return json(res, 404, { error: 'not found' })
}
// ── DoStuff (n8n) : statut LIVE d'un modem via le MÊME webhook que F (GET {sn, olt}) ──────────────────
// F appelle n8napi.targo.ca/webhook/dostuff en GET avec un body JSON → on réplique via le module https (fetch
// refuse un body en GET). LENT (~25-30 s : n8n interroge l'OLT en direct) → action À LA DEMANDE seulement.
// Réservé tech=3 (OLT reconnus par n8n) ; tech=2 (poller OLT standard) → n8n renvoie « OLT non reconnue » → repli sur notre statut.
function dostuffStatus ({ sn, olt } = {}) {
return new Promise((resolve) => {
if (!sn || !olt) return resolve({ ok: false, error: 'sn + olt requis' })
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const body = JSON.stringify({ sn: String(sn), olt: String(olt) })
const req = https.request('https://n8napi.targo.ca/webhook/dostuff', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 40000 }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
const r = Array.isArray(arr) ? arr[0] : arr
if (!r || r.ErrorCode) return resolve({ ok: false, recognized: false, error: (r && r.ErrorCode) || 'réponse vide' }) // OLT non reconnue (tech=2) → repli
const clean = (v) => String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim()
const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
const online = clean(r.Status) === '1' || clean(r.InternetOn) === '1'
resolve({
ok: true, source: 'dostuff', online, label: online ? 'En ligne' : 'Hors ligne',
rxPower: num(r.RxSignal), txPower: num(r.TxSignal), signal: signalFrom(num(r.RxSignal)), distance: num(r.Distance),
uptime: clean(r.Uptime) || null, lastDown: clean(r.LastDown) || null, firmware: clean(r.Firmware) || null,
managementIp: clean(r.Management) || null, wanIp: clean(r.Internet) || null, voip: clean(r.VoIP) || null, iptv: clean(r.IPTV) || null,
profileId: clean(r.ProfileID) || null, slot: clean(r.Slot) || null, port: clean(r.Port) || null, onuId: clean(r.OnuID) || null,
})
})
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~40 s) — OLT lent ou modem hors ligne' }) })
req.write(body); req.end()
})
}
// Signal optique fibre RAPIDE (TP-Link / tech-3) via le webhook n8n dédié `get_signal_tplink` (~3-4 s) — c'est celui
// que F utilise pour la pastille de signal instantané. Renvoie SEULEMENT rx/tx dBm + online (pas distance/uptime/etc.,
// pour ça → dostuffStatus, ~27 s). online: true (Status 1) / false (Status 0) / null (statut vide = indéterminé → repli).
function fiberSignalFast ({ sn, olt } = {}) {
return new Promise((resolve) => {
if (!sn || !olt) return resolve({ ok: false, error: 'sn + olt requis' })
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const body = JSON.stringify({ sn: String(sn), olt: String(olt) })
const req = https.request('https://n8napi.targo.ca/webhook/get_signal_tplink', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 15000 }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
let j; try { j = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
const r = Array.isArray(j) ? j[0] : j
if (!r) return resolve({ ok: false, error: 'réponse vide' })
const clean = (v) => String(v == null ? '' : v).replace(/"/g, '').trim()
const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
const st = clean(r.Status)
resolve({ ok: true, source: 'get_signal_tplink', rx_dbm: num(r.RxSignal), tx_dbm: num(r.TxSignal), signal: signalFrom(num(r.RxSignal)), online: st === '1' ? true : (st === '0' ? false : null) })
})
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~15 s)' }) })
req.write(body); req.end()
})
}
// ── airOS/Cambium (sans-fil fixe) : signal LIVE d'un CPE. Le hub NE JOINT PAS les CPE (réseau terrain non routable —
// vérifié : timeouts). PRIMAIRE = pont F `ops_airos.php` sur facturation (F joint les CPE + a le code airOS + les
// creds ; même auth X-Ops-Token que ops_reassign.php). REPLI = webhook n8n `get_signal_airos` (chemin post-migration
// F ; 404 tant qu'il n'existe pas). Miroir de F `device_ajax/airos_ac_ajax.php` (status.cgi).
function opsBridgeToken () { try { return (process.env.OPS_LEGACY_TOKEN || require('fs').readFileSync('/app/data/ops_legacy.token', 'utf8') || '').trim() } catch (e) { return (process.env.OPS_LEGACY_TOKEN || '').trim() } }
function normalizeAiros (r) {
const clean = (v) => { const s = String(v == null ? '' : v).replace(/^"+|"+$/g, '').trim(); return s || null }
const num = (v) => { const n = parseFloat(clean(v)); return isNaN(n) ? null : n }
return {
ok: true, source: r.source || 'airos',
model: clean(r.device_model || r.model), name: clean(r.device_name || r.name), version: clean(r.version),
ssid: clean(r.ssid), frequency: clean(r.frequency), txPower: clean(r.tx_power || r.txPower), apMac: clean(r.mac_ap || r.apMac), apIp: clean(r.ap_ip || r.apIp),
signal: num(r.signal), signalAp: num(r.signal_ap || r.signalAp), ccq: num(r.ccq),
txRate: clean(r.tx_rate || r.txRate), rxRate: clean(r.rx_rate || r.rxRate),
capUp: num(r.airmax_capacity_uplink || r.capUp), capDown: num(r.airmax_capacity_downlink || r.capDown),
uptime: clean(r.connection_time || r.uptime), lan0: clean(r.if_speed_lan0 || r.lan0), lan1: clean(r.if_speed_lan1 || r.lan1),
// RADIUS (WPA2) : MAC courante autoritaire (auto-MàJ au remplacement) + IP AP (nasipaddress). Débit eth0 : compteurs + ts.
radiusMac: clean(r.radius_mac || r.radiusMac), radiusUser: clean(r.radius_user || r.radiusUser),
ethRx: (r.eth_rx_bytes != null ? Number(r.eth_rx_bytes) : null), ethTx: (r.eth_tx_bytes != null ? Number(r.eth_tx_bytes) : null),
ts: (r.ts != null ? Number(r.ts) : null),
}
}
function httpsGetJson (targetUrl, headers, timeoutMs) {
return new Promise((resolve) => {
let https; try { https = require('https') } catch (e) { return resolve({ _err: 'https indisponible' }) }
const req = https.request(targetUrl, { method: 'GET', headers: headers || {}, timeout: timeoutMs || 20000 }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
let j; try { j = JSON.parse(d) } catch (e) { return resolve({ _err: 'réponse illisible', _status: res.statusCode }) }
if (j && typeof j === 'object') j._status = res.statusCode
resolve(j)
})
})
req.on('error', e => resolve({ _err: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ _err: 'délai dépassé' }) })
req.end()
})
}
const macNorm = (m) => String(m || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase()
// AUTO-SYNC : RADIUS = MAC autoritaire. Si elle diffère de l'enregistrée → MàJ Service Equipment (idempotent).
async function autoSyncMac (out, seName, seMac) {
if (!(out && out.ok && out.radiusMac && seName)) return
if (!macNorm(out.radiusMac) || macNorm(out.radiusMac) === macNorm(seMac)) return
const res = await erp.update('Service Equipment', seName, { mac_address: out.radiusMac }).catch(e => ({ ok: false, error: String(e.message || e) }))
if (res && res.ok) { out.macSynced = true; out.macWas = seMac || null; log('[airos] MAC auto-sync ' + seName + ' ' + (seMac || '∅') + ' → ' + out.radiusMac + ' (RADIUS)') }
else { out.macSyncError = String((res && res.error) || 'échec').slice(0, 120) } // ex. lien service_location cassé → non bloquant
}
async function airosSignal ({ sn, ip, port, user, mac } = {}) {
// Résout le Service Equipment (nom + IP + MAC enregistrée) — sert à l'AUTO-SYNC de la MAC depuis RADIUS.
let seName = null, seMac = ''
try {
let rows = []
if (sn) rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
if ((!rows || !rows.length) && ip) rows = await erp.list('Service Equipment', { filters: [['ip_address', '=', String(ip)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
const e = rows && rows[0]
if (e) { seName = e.name; seMac = e.mac_address || ''; if (!ip) ip = e.ip_address || ''; if (!mac) mac = e.mac_address || '' }
} catch (e) { /* best-effort */ }
if (!sn && !ip && !mac) return { ok: false, error: 'serial, ip ou mac requis' }
// PRIMAIRE : pont F ops_airos.php (même hôte + token que ops_reassign.php).
const token = opsBridgeToken()
const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
let out = null
if (token && base) {
const q = new URLSearchParams()
if (sn) q.set('serial', sn); if (ip) q.set('ip', ip); if (mac) q.set('mac', mac); if (port) q.set('port', String(port))
const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 25000)
if (r && r.ok === true) out = normalizeAiros(r)
else if (r && r.ok === false && r.error && !r._err) return { ok: false, error: r.error } // réponse F propre (device introuvable / CPE injoignable)
// transport KO (r._err) → tenter n8n en repli
}
// REPLI : webhook n8n get_signal_airos (post-migration F ; 404 = pas encore créé).
if (!out) out = await airosViaN8n({ sn, ip, port, user })
// AUTO-SYNC MAC : RADIUS (WPA2) = autoritaire, s'auto-MàJ au remplacement → on aligne Service Equipment (serveur).
await autoSyncMac(out, seName, seMac)
return out
}
// STATUS RAPIDE (chargement de page) : état via RADIUS seul (aucun status.cgi ~5 s). En ligne / depuis / MAC / IPs.
async function airosStatus ({ sn, ip } = {}) {
let seName = null, seMac = ''
try {
let rows = []
if (sn) rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
if ((!rows || !rows.length) && ip) rows = await erp.list('Service Equipment', { filters: [['ip_address', '=', String(ip)]], fields: ['name', 'ip_address', 'mac_address'], limit: 1 })
const e = rows && rows[0]; if (e) { seName = e.name; seMac = e.mac_address || ''; if (!ip) ip = e.ip_address || '' }
} catch (e) { /* best-effort */ }
if (!sn && !ip) return { ok: false, error: 'serial ou ip requis' }
const token = opsBridgeToken()
const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
if (!token || !base) return { ok: false, error: 'pont F non configuré' }
const q = new URLSearchParams({ mode: 'status' })
if (sn) q.set('serial', sn); if (ip) q.set('ip', ip)
const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 15000)
if (!r || r._err) return { ok: false, error: (r && r._err) || 'pont F injoignable' }
if (r.ok === false) return { ok: false, error: r.error }
const out = { ok: true, kind: 'sans-fil', online: (r.online === true || r.online === false) ? r.online : null, since: r.since || null, model: r.model || null, radiusMac: r.radius_mac || null, framedIp: r.framed_ip || null, apIp: r.ap_ip || null }
await autoSyncMac(out, seName, seMac) // le status porte aussi la MAC RADIUS → auto-sync au chargement de page
return out
}
// Baux DHCP d'un CPE sans-fil (= appareils derrière le CPE, façon F) via le pont F (SSH → /tmp/dhcpd.leases).
async function airosLeases ({ sn, ip } = {}) {
if (!ip && sn) { try { const rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['ip_address'], limit: 1 }); if (rows && rows[0]) ip = rows[0].ip_address || '' } catch (e) { /* */ } }
if (!sn && !ip) return { ok: false, error: 'serial ou ip requis' }
const token = opsBridgeToken()
const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
if (!token || !base) return { ok: false, error: 'pont F non configuré' }
const q = new URLSearchParams({ mode: 'leases' }); if (sn) q.set('serial', sn); if (ip) q.set('ip', ip)
const r = await httpsGetJson(base + '?' + q.toString(), { 'X-Ops-Token': token }, 20000)
if (!r || r._err) return { ok: false, error: (r && r._err) || 'pont F injoignable' }
return r
}
// Réconciliation MAC park-wide (job quotidien 03 h) : 1 appel bulk F (radacct courant) → MàJ Service Equipment changées.
async function syncWirelessMacs () {
const token = opsBridgeToken()
const base = (process.env.OPS_LEGACY_URL || 'https://facturation.targo.ca/ops_reassign.php').replace(/ops_reassign\.php.*$/, 'ops_airos.php')
if (!token || !base) return { ok: false, error: 'pont F non configuré' }
const r = await httpsGetJson(base + '?bulk=1', { 'X-Ops-Token': token }, 60000)
if (!r || r._err || !Array.isArray(r.devices)) return { ok: false, error: (r && r._err) || 'bulk KO' }
// Index des Service Equipment sans-fil par IP de gestion (clé stable même quand la MAC change).
const existing = await erp.list('Service Equipment', { filters: [['ip_address', '!=', '']], fields: ['name', 'ip_address', 'mac_address'], limit: 8000 }).catch(() => [])
const byIp = {}; for (const e of existing) if (e.ip_address) byIp[String(e.ip_address).trim()] = e
let checked = 0, updated = 0, errs = 0, brokenLoc = 0
for (const d of r.devices) {
if (!d.radius_mac || !d.manage) continue
const se = byIp[String(d.manage).trim()]; if (!se) continue
checked++
if (macNorm(se.mac_address) !== macNorm(d.radius_mac)) {
const res = await erp.update('Service Equipment', se.name, { mac_address: d.radius_mac }).catch(e => ({ ok: false, error: String(e.message || e) }))
if (res && res.ok) updated++
else { errs++; if (/LinkValidation|Lieu de service/i.test(String(res && res.error))) brokenLoc++ }
}
}
log('[airos] daily MAC sync — bulk ' + r.devices.length + ' · checked ' + checked + ' · updated ' + updated + ' · errs ' + errs + (brokenLoc ? ' (dont ' + brokenLoc + ' lien service_location cassé)' : ''))
return { ok: true, bulk: r.devices.length, checked, updated, errs, broken_location: brokenLoc }
}
// Planificateur quotidien 03 h ET (tick /30 min ; s'exécute 1×/jour dans la fenêtre 03h). Désactivable WIRELESS_MAC_SYNC=off.
// ── FIBRE : l'ONU COURANT est dans la table F `fibre` (autoritaire — F la met à jour au remplacement d'ONT). OPS
// Service Equipment garde `legacy_fibre_id` → on relit fibre.sn/slot/port/ontid/info_connect et on aligne (le serial
// OPS devient périmé après un swap → dostuff par l'ancien serial = « hors ligne » à tort). Le hub joint gestionclient.
let _fpool = null
function fPool () {
if (_fpool) return _fpool
let mysql; try { mysql = require('mysql2/promise') } catch (e) { return null }
_fpool = mysql.createPool({ host: process.env.LEGACY_DB_HOST || '10.100.80.100', user: process.env.LEGACY_DB_USER || 'facturation', password: process.env.LEGACY_DB_PASS, database: process.env.LEGACY_DB_NAME || 'gestionclient', connectionLimit: 2, connectTimeout: 8000 })
return _fpool
}
async function fibreById (fibreId) {
const p = fPool(); if (!p || !fibreId) return null
try { const [rows] = await p.query('SELECT id, sn, tech, info_connect, slot, port, ontid FROM fibre WHERE id=? LIMIT 1', [Number(fibreId)]); return (rows && rows[0]) || null } catch (e) { return null }
}
// Aligne l'ONU OPS sur la ligne F fibre (serial + gpon_serial + olt_ip/slot/port/ontid). Renvoie {changed, was}.
async function reconcileFibreOnu (se, f) {
const changed = {}
if (f.sn && String(f.sn) !== String(se.serial_number || '')) { changed.serial_number = f.sn; changed.gpon_serial = f.sn }
if (f.info_connect && String(f.info_connect) !== String(se.olt_ip || '')) changed.olt_ip = f.info_connect
if (f.slot != null && Number(f.slot) !== Number(se.olt_slot)) changed.olt_slot = Number(f.slot)
if (f.port != null && Number(f.port) !== Number(se.olt_port)) changed.olt_port = Number(f.port)
if (f.ontid != null && Number(f.ontid) !== Number(se.olt_ontid)) changed.olt_ontid = Number(f.ontid)
if (!Object.keys(changed).length) return { changed: null }
const res = await erp.update('Service Equipment', se.name, changed).catch(e => ({ ok: false, error: e.message }))
if (res && res.ok) { const was = se.serial_number || null; Object.assign(se, changed); log('[fibre] ONU sync ' + se.name + ' ' + (was || '∅') + ' → ' + (changed.serial_number || se.serial_number) + ' (F fibre)'); return { changed, was } }
return { changed: null, error: (res && res.error) }
}
// Statut fibre LIVE : reconcilie l'ONU depuis F fibre (serial COURANT après swap) PUIS dostuff avec le BON serial.
async function fibreLive ({ equipment, serial, olt } = {}) {
let se = null, onuSynced = false, onuWas = null
if (equipment) se = await erp.get('Service Equipment', equipment, { fields: ['name', 'serial_number', 'gpon_serial', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'legacy_fibre_id'] }).catch(() => null)
if (se && se.legacy_fibre_id) {
const f = await fibreById(se.legacy_fibre_id)
if (f && f.sn) { const rc = await reconcileFibreOnu(se, f); if (rc.changed) { onuSynced = true; onuWas = rc.was } serial = serial || f.sn; olt = olt || f.info_connect }
}
serial = serial || (se && se.serial_number); olt = olt || (se && se.olt_ip)
if (!serial || !olt) return { ok: false, error: 'ONU non résolu (serial/olt manquant)', onuSynced, onuWas }
const status = await dostuffStatus({ sn: serial, olt })
return { ...status, serial, onuSynced, onuWas }
}
// Reconcile fibre park-wide (job quotidien) : SE fibre (legacy_fibre_id) → F fibre (en lots) → aligne les changées.
async function syncFibreOnus () {
const p = fPool(); if (!p) return { ok: false, error: 'F DB indisponible' }
const ses = await erp.list('Service Equipment', { filters: [['legacy_fibre_id', '>', 0]], fields: ['name', 'serial_number', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'legacy_fibre_id'], limit: 20000 }).catch(() => [])
const byFib = {}; const ids = ses.map(s => Number(s.legacy_fibre_id)).filter(Boolean)
for (let i = 0; i < ids.length; i += 500) {
const batch = ids.slice(i, i + 500); if (!batch.length) break
try { const [rows] = await p.query('SELECT id, sn, info_connect, slot, port, ontid FROM fibre WHERE id IN (' + batch.map(() => '?').join(',') + ')', batch); for (const r of rows) byFib[Number(r.id)] = r } catch (e) { /* */ }
}
let checked = 0, updated = 0, errs = 0
for (const se of ses) {
const f = byFib[Number(se.legacy_fibre_id)]; if (!f || !f.sn) continue
checked++
const rc = await reconcileFibreOnu(se, f)
if (rc.changed) updated++; else if (rc.error) errs++
}
log('[fibre] daily ONU sync — SE ' + ses.length + ' · checked ' + checked + ' · updated ' + updated + ' · errs ' + errs)
return { ok: true, se: ses.length, checked, updated, errs }
}
let _lastMacSyncDay = null; let _macSyncStarted = false
function startWirelessMacSync () {
if (process.env.WIRELESS_MAC_SYNC === 'off') return
const tick = async () => {
try {
const now = new Date()
const day = now.toLocaleDateString('en-CA', { timeZone: 'America/Toronto' })
const hour = parseInt(new Intl.DateTimeFormat('en-US', { timeZone: 'America/Toronto', hour: 'numeric', hour12: false }).format(now), 10)
if (hour === 3 && _lastMacSyncDay !== day) { _lastMacSyncDay = day; await syncWirelessMacs(); await syncFibreOnus() }
} catch (e) { log('[airos] mac-sync tick: ' + e.message) }
}
setInterval(tick, 30 * 60 * 1000)
log('[airos] wireless MAC daily reconcile scheduled (03 h ET)')
}
function airosViaN8n ({ sn, ip, port, user } = {}) {
return new Promise((resolve) => {
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const body = JSON.stringify({ sn: String(sn || ''), ip: String(ip || ''), port: Number(port) || 2196, user: String(user || 'admin') })
const req = https.request('https://n8napi.targo.ca/webhook/get_signal_airos', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 25000 }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
if (res.statusCode === 404) return resolve({ ok: false, registered: false, error: 'Signal sans-fil indisponible (pont F injoignable + webhook n8n « get_signal_airos » absent)' })
let arr; try { arr = JSON.parse(d) } catch (e) { return resolve({ ok: false, error: 'réponse n8n illisible' }) }
const r = Array.isArray(arr) ? arr[0] : arr
if (!r || r.ErrorCode || r.error) return resolve({ ok: false, error: (r && (r.ErrorCode || r.error)) || 'réponse vide (CPE injoignable ?)' })
resolve(normalizeAiros(r))
})
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé (~25 s) — CPE lent ou hors ligne' }) })
req.write(body); req.end()
})
}
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus, fiberSignalFast, airosSignal, airosStatus, airosLeases, syncWirelessMacs, startWirelessMacSync, searchCustomers, fibreLive, syncFibreOnus }