gigafibre-fsm/services/targo-hub/lib/ticket-collab.js
louispaulb e950bd3ac6 hub: reverse geocoding infra-first (fibre field-pins -> RQA fallback); address resolution + geotagged field photos
- /address/reverse: reverseNearestFibre() (fibre field-pins, source of truth) then RQA
- searchCustomers: civic address resolves to the resident (not fuzzy homonyms)
- field-app + /field/photo: geotagged photos confirm the correct job

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 21:52:14 -04:00

797 lines
59 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

'use strict'
// ── 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, '&lt;').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
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":"<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/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()
})
}
module.exports = { handle, addComment, activity, createTicket, serviceStatus, dostuffStatus }