The hub can't reach the CPE network, so wireless signal now flows through a
new read-only F endpoint that CAN (F sits on that network + already has the
airOS code + device creds):
- services/legacy-bridge/ops_airos.php — token-gated (X-Ops-Token, same secret
as ops_reassign.php), SSRF-safe (only curls a mgmt IP present in F's `device`
table). Resolves device by serial/mac/ip, logs into the CPE (airOS >=8.5
/api/auth or <8.5 /login.cgi), reads status.cgi, returns normalized signal /
signal-AP / CCQ / TX-RX rate / airMAX capacity / model / uptime. v8 gets full
data; v6 falls back to top-level wireless.signal; CCQ clamped to 0-100.
- hub: airosSignal now calls the F bridge FIRST (OPS_LEGACY_URL host +
/app/data/ops_legacy.token), n8n webhook demoted to post-migration fallback.
Shared normalizeAiros() for both paths.
Verified end-to-end (hub -> F -> CPE): NanoBeam 5AC signal -53 / airMAX
140/143 Mbps; v6 NanoStation signal -47; offline CPE -> clean {ok:false}.
Auth reject + SSRF guard verified. docs updated (F bridge primary).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
886 lines
66 KiB
JavaScript
886 lines
66 KiB
JavaScript
'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')
|
||
|
||
// 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) — FUZZY : normalisé (sans séparateurs/accents/casse) + similarité trigramme (score façon Google, tolère typos).
|
||
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 {
|
||
// word_similarity($q, nom) = meilleure sous-chaîne → « bourdn » matche « andrebourdon ». Seuil explicite (pas de GUC).
|
||
// On INCLUT les clients désactivés (anciens / réactivation) mais on classe les ACTIFS d'abord (+ badge « inactif » côté UI).
|
||
rows = (await p.query(
|
||
`SELECT name, customer_name, word_similarity($1, ${NORM}) AS score FROM "tabCustomer"
|
||
WHERE (${NORM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORM}) > 0.4)
|
||
ORDER BY (${NORM} LIKE '%'||$1||'%') DESC, coalesce(disabled,0) ASC, score DESC NULLS LAST LIMIT 12`, [qn])).rows
|
||
} catch (e) {
|
||
// Repli si pg_trgm indisponible : sous-chaîne normalisée seule.
|
||
try { rows = (await p.query(`SELECT name, customer_name FROM "tabCustomer" WHERE ${NORM} LIKE '%'||$1||'%' ORDER BY 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 })
|
||
}
|
||
}
|
||
// 3b) Mandataire / contact détaillé (titulaire ≠ demandeur, ex. représentant qui paie/écrit). FUZZY sur les champs legacy.
|
||
// try/catch : si les colonnes custom n'existent pas encore, on ignore silencieusement (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')"
|
||
try {
|
||
const rows = (await p.query(
|
||
`SELECT name, customer_name, word_similarity($1, ${NORMM}) AS score FROM "tabCustomer"
|
||
WHERE (${NORMM} LIKE '%'||$1||'%' OR word_similarity($1, ${NORMM}) > 0.45)
|
||
ORDER BY (${NORMM} LIKE '%'||$1||'%') DESC, 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 })
|
||
} 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,
|
||
}
|
||
})
|
||
}
|
||
|
||
// 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 ? ('<div data-mentions="' + ment.join(',') + '">↪ ' + ment.map(shortName).join(', ') + '</div>') : ''
|
||
const r = await erp.create('Comment', {
|
||
comment_type: 'Comment', reference_doctype: doctype, reference_name: name,
|
||
content: tag + '<div>' + content.replace(/</g, '<').replace(/\n/g, '<br>') + '</div>' + (agent ? '<div style="color:#94a3b8">— ' + shortName(agent) + ' (OPS)</div>' : ''),
|
||
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()
|
||
const st = (status === 'On Hold' || status === 'pending' || status === 'En attente') ? 'On Hold' : 'Open' // « pending » = suspendu jusqu'à la date de rappel
|
||
const data = {
|
||
subject: (cat ? '[' + 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
|
||
// 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":"<nom/adresse/téléphone du client tel que dicté, ou null>","subject":"<sujet court>","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":"<nom/adresse/téléphone du destinataire tel que dicté>","message":"<le texto>"}
|
||
- {"type":"send_email","to_query":"<...>","subject":"<...>","body":"<...>"}
|
||
- {"type":"note","text":"<note interne>"}
|
||
- {"type":"diagnose","customer_query":"<nom/adresse/téléphone du client tel que dicté>"} // 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) {
|
||
// 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/<token> — 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=<adresse|nom|téléphone|courriel>[&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=<sn>&olt=<olt_ip> — 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=<sn>[&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/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=<sn>[&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()
|
||
})
|
||
}
|
||
|
||
// ── 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),
|
||
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),
|
||
}
|
||
}
|
||
|
||
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()
|
||
})
|
||
}
|
||
|
||
async function airosSignal ({ sn, ip, port, user, mac } = {}) {
|
||
// Résout ip/mac depuis Service Equipment (source unique du parc) si non fournis.
|
||
if ((!ip || !mac) && sn) {
|
||
try {
|
||
const rows = await erp.list('Service Equipment', { filters: [['serial_number', '=', String(sn)]], fields: ['ip_address', 'mac_address'], limit: 1 })
|
||
const e = rows && rows[0]
|
||
if (e) { 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')
|
||
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) return normalizeAiros(r)
|
||
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éé).
|
||
return await airosViaN8n({ sn, ip, port, user })
|
||
}
|
||
|
||
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, airosSignal }
|