'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 */ } // 2) No civique → adresse (Service Location → client) if (p && civic && found.size < 8) { try { const street = text.split(' ').filter(w => w.length >= 3).slice(0, 2) const cond = street.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`, street.length ? ['%' + civic + '%', '%' + street.join('%') + '%'] : ['%' + civic + '%']) for (const row of r.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) } 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 < 8) { 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 8`, [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 < 8) { 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, 8) 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 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 const link = 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 = ERP_URL() + '/app/issue/' + encodeURIComponent(r.name) 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) { // 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= — autosuggest client par texte libre (nom / courriel / téléphone / adresse) if (p === '/collab/customer-search' && method === 'GET') { return json(res, 200, { matches: await searchCustomers(url.searchParams.get('q') || '') }) } // 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/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() }) } module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus }