gigafibre-fsm/services/targo-hub/lib/olt-ops.js
louispaulb 23dde4ecf0 feat(ops): native Payment Method/Arrangement + VoIP Line sections + G3 Raisecom SNMP writes
Native detail sections (fin de la grille+desk pour les derniers doctypes cliquables de la fiche client) :
- PaymentMethodDetail / PaymentArrangementDetail / VoipLineDetail — LECTURE SEULE (aucune charge/paiement déclenché ; mot de passe SIP jamais affiché). Enregistrés dans SECTION_MAP.

Phase 2 G3 (parité écriture fibre tech-2 Raisecom, ~10 500 ONUs) :
- olt-ops.js : écritures SNMP natives (net-snmp) suspend/rétablir (VLAN 40↔666) / redémarrer / vitesse (line-profile), OIDs EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source) + SAVE 8886.1.2.1.1.0 après chaque set. Encodages olt_id (slot*1e7+port*1e5+ontid) et onu_id ((slot+32)*8388608+port*65536+ontid) — round-trip vérifié. Repli coords ONU depuis le poller SNMP live (olt-snmp.getOnuBySerial) car les coords Raisecom ne sont pas dans Service Equipment. Gaté : env OLT_RW_COMMUNITY requis (absent → run refuse proprement, 0 set), confirm+idempotencyKey obligatoires. Vérifié live : plan tech-2 refuse sans coords/RW ; run refuse ; 0 écriture réseau tirée.
- OnuActionsPanel : ajout « Redémarrer ».

Déployé prod (hub olt-ops + SPA). DetailModal SECTION_MAP (5 entrées ajoutées) = déployé mais co-édité → non commité.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 22:39:59 -04:00

251 lines
18 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'
// ══════════════════════════════════════════════════════════════════════════════
// olt-ops.js — ÉCRITURES cycle-de-vie ONU (activate / replace / speed / suspend /
// remove) pour la fibre TP-Link tech-3 (XGS-PON), en enveloppant les MÊMES verbes
// n8n que F (`/webhook/dostuff`, le VERBE HTTP = l'action). C'est le port G2 du
// plan « Do Stuff » : OPS lisait déjà tout (olt-snmp) mais n'écrivait RIEN à l'OLT.
//
// SÉCURITÉ (leçons PPA + F sans idempotence) :
// • plan() = LECTURE SEULE — résout le contexte (OLT/slot/port/profil depuis
// Service Equipment), retourne le payload EXACT + effets + avertissements. 0 réseau.
// • run() = fire le verbe n8n PUIS miroir ERPNext. Exige confirm:true + une
// idempotency-key ; re-jouer la MÊME clé ne re-tire PAS (cache /app/data/olt_ops.json).
// • tech-2 (Raisecom, RCMG) = SNMP direct côté F → n8n renvoie « OLT non reconnue » ;
// plan() le signale (écriture SNMP native = G3, pas encore branchée) et refuse run().
// ══════════════════════════════════════════════════════════════════════════════
const fs = require('fs')
const erp = require('./erp')
const { log } = require('./helpers')
const N8N_DOSTUFF = 'https://n8napi.targo.ca/webhook/dostuff'
const STORE = '/app/data/olt_ops.json'
const IDEMP_WINDOW_MS = 10 * 60 * 1000 // rejouer la même clé dans 10 min = même résultat (pas de 2e tir)
// Communauté SNMP EN ÉCRITURE (Raisecom tech-2). Secret → env seulement, JAMAIS bundlé. Absente ⇒ run() tech-2 refuse.
const OLT_RW_COMMUNITY = process.env.OLT_RW_COMMUNITY || ''
const OLT_RO_COMMUNITY = process.env.OLT_RO_COMMUNITY || 'targosnmp'
// OIDs Raisecom (MIB 8886) — EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source, pas reconstruits) :
// reboot 1.3.6.1.4.1.8886.18.3.6.1.1.1.23.{olt_id}.4 = i 1 (olt_id = slot*1e7+port*1e5+ontid)
// vlan 1.3.6.1.4.1.8886.18.3.6.6.1.1.7.{olt_id}.4 = i 40|666 (40=actif, 666=suspendu)
// profile 1.3.6.1.4.1.8886.18.3.1.3.1.1.6.{onu_id} = i profil (onu_id = (slot+32)*8388608+port*65536+ontid)
// save 1.3.6.1.4.1.8886.1.2.1.1.0 = i 2 (APRÈS chaque set)
const RC = {
REBOOT: (oltId) => `1.3.6.1.4.1.8886.18.3.6.1.1.1.23.${oltId}.4`,
VLAN: (oltId) => `1.3.6.1.4.1.8886.18.3.6.6.1.1.7.${oltId}.4`,
PROFILE: (onuId) => `1.3.6.1.4.1.8886.18.3.1.3.1.1.6.${onuId}`,
SAVE: '1.3.6.1.4.1.8886.1.2.1.1.0',
VLAN_ACTIVE: 40, VLAN_SUSPEND: 666,
}
const rcOltId = (s, p, o) => Number(s) * 10000000 + Number(p) * 100000 + Number(o)
const rcOnuId = (s, p, o) => (Number(s) + 32) * 8388608 + Number(p) * 65536 + Number(o)
// action → verbe n8n (tech-3), champs requis, techs supportées. tech-3 = n8n ; tech-2 = SNMP natif (G3).
const ACTIONS = {
activate: { verb: 'POST', label: 'Activer / provisionner lONU', needs: ['sn', 'olt', 'profileid'], techs: [3] },
replace: { verb: 'PUT', label: 'Remplacer lONU (échange de série)', needs: ['old_sn', 'new_sn', 'olt'], techs: [3] },
speed: { verb: 'PATCH', label: 'Changer le forfait / la vitesse', needs: ['sn', 'olt', 'profileid'], extra: { trigger: 'speedchange' }, techs: [3, 2] },
suspend: { verb: 'PATCH', label: 'Suspendre linternet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'suspend' }, techs: [3, 2] },
unsuspend: { verb: 'PATCH', label: 'Rétablir linternet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'unsuspend' }, techs: [3, 2] },
remove: { verb: 'DELETE', label: 'Retirer / désactiver lONU', needs: ['sn', 'olt'], techs: [3] },
reboot: { verb: null, label: 'Redémarrer lONU', needs: ['sn', 'olt'], techs: [2] }, // tech-3 : utiliser le reboot TR-069
}
// SNMP SET Raisecom (tech-2) : applique l'action + SAVE, via net-snmp. Renvoie {ok,error}. LECTURE : voir olt-snmp.
function snmpRaisecom (action, { host, slot, port, ontid, profileid } = {}) {
return new Promise((resolve) => {
if (!OLT_RW_COMMUNITY) return resolve({ ok: false, error: 'OLT_RW_COMMUNITY non configurée (env) — écriture SNMP tech-2 désactivée' })
if (host == null || slot == null || port == null || ontid == null) return resolve({ ok: false, error: 'slot/port/ontid/olt requis' })
let snmp; try { snmp = require('net-snmp') } catch (e) { return resolve({ ok: false, error: 'net-snmp indisponible' }) }
const oltId = rcOltId(slot, port, ontid); const onuId = rcOnuId(slot, port, ontid)
let vb
if (action === 'suspend') vb = { oid: RC.VLAN(oltId), type: snmp.ObjectType.Integer, value: RC.VLAN_SUSPEND }
else if (action === 'unsuspend') vb = { oid: RC.VLAN(oltId), type: snmp.ObjectType.Integer, value: RC.VLAN_ACTIVE }
else if (action === 'reboot') vb = { oid: RC.REBOOT(oltId), type: snmp.ObjectType.Integer, value: 1 }
else if (action === 'speed') { const pr = parseInt(profileid, 10); if (!pr) return resolve({ ok: false, error: 'profileid (n° profil de ligne) requis pour le changement de vitesse' }); vb = { oid: RC.PROFILE(onuId), type: snmp.ObjectType.Integer, value: pr } }
else return resolve({ ok: false, error: 'action SNMP tech-2 non supportée: ' + action })
const session = snmp.createSession(String(host), OLT_RW_COMMUNITY, { timeout: 6000, retries: 1, version: snmp.Version2c })
session.set([vb], (err) => {
if (err) { try { session.close() } catch (e) {} return resolve({ ok: false, error: 'SNMP set: ' + err.message }) }
// SAVE obligatoire après un set (sinon revert silencieux) — sauf reboot (F ne sauve pas après un reboot).
if (action === 'reboot') { try { session.close() } catch (e) {} return resolve({ ok: true, oid: vb.oid }) }
session.set([{ oid: RC.SAVE, type: snmp.ObjectType.Integer, value: 2 }], (err2) => {
try { session.close() } catch (e) {}
if (err2) return resolve({ ok: false, error: 'SNMP save: ' + err2.message, warn: 'set appliqué mais save échoué — peut se réverter' })
resolve({ ok: true, oid: vb.oid, saved: true })
})
})
})
}
function readStore () { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch (e) { return {} } }
function writeStore (o) { try { fs.writeFileSync(STORE, JSON.stringify(o)) } catch (e) { log('olt-ops store write: ' + e.message) } }
// tech-3 (TP-Link XGS-PON) = n8n ; tech-2 (Raisecom) = SNMP direct côté F (pas n8n).
function techOf (serial, equip) {
const s = String(serial || (equip && equip.serial_number) || '').toUpperCase()
if (equip && equip.fibre_tech) return Number(equip.fibre_tech)
if (s.startsWith('TPLG')) return 3
if (s.startsWith('RCMG')) return 2
return null // inconnu
}
// Résout le contexte réseau d'un ONU depuis Service Equipment (miroir de la table `fibre` de F).
async function resolveEquip (serial) {
// ⚠️ Ne PAS demander fibre_line_profile/fibre_service_profile ici : ce ne sont PAS des DocFields v16
// (erp.list les strippe → bruit + latence). Le profil (activate/speed) est fourni par l'appelant
// (l'UI le collecte) ; suspend/unsuspend/remove n'en ont pas besoin.
const rows = await erp.list('Service Equipment', {
filters: [['serial_number', '=', String(serial)]],
fields: ['name', 'serial_number', 'mac_address', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'customer', 'service_location', 'status', 'equipment_type'],
limit: 1,
}).catch(() => [])
const equip = (rows && rows[0]) || null
// Repli coordonnées ONU depuis le poller SNMP live (olt-snmp) : les coords Raisecom (tech-2) ne sont PAS mirorées
// dans Service Equipment (elles vivent dans la table `fibre` de F) → sans ça, l'écriture SNMP tech-2 est impossible.
if (!equip || equip.olt_ip == null || equip.olt_slot == null || equip.olt_ontid == null) {
try {
const onu = require('./olt-snmp').getOnuBySerial(String(serial))
if (onu) {
const merged = equip || { serial_number: String(serial) }
if (merged.olt_ip == null) merged.olt_ip = onu.oltHost || null
if (merged.olt_slot == null) merged.olt_slot = (onu.slot != null ? onu.slot : null)
if (merged.olt_port == null) merged.olt_port = (onu.port != null ? onu.port : null)
if (merged.olt_ontid == null) merged.olt_ontid = (onu.ontId != null ? onu.ontId : (onu.ontid != null ? onu.ontid : null))
merged._coords_from_snmp = true
return merged
}
} catch (e) { /* poller indispo → on garde equip tel quel */ }
}
return equip
}
// Appel n8n dostuff avec un VERBE arbitraire + body JSON (le module https, comme dostuffStatus).
function dostuff (verb, payload, timeoutMs = 60000) {
return new Promise((resolve) => {
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const body = JSON.stringify(payload)
const req = https.request(N8N_DOSTUFF, { method: verb, headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: timeoutMs }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => {
let parsed; try { parsed = JSON.parse(d) } catch (e) { parsed = d }
const r = Array.isArray(parsed) ? parsed[0] : parsed
const recognized = !(r && r.ErrorCode)
resolve({ ok: recognized && res.statusCode < 400, status: res.statusCode, recognized, error: (r && r.ErrorCode) || null, data: r })
})
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai n8n dépassé (~' + Math.round(timeoutMs / 1000) + ' s)' }) })
req.write(body); req.end()
})
}
// Construit le payload n8n EXACT pour une action, à partir du contexte résolu + overrides.
function buildPayload (action, equip, opts = {}) {
const spec = ACTIONS[action]
const olt = opts.olt || (equip && equip.olt_ip) || ''
const sn = opts.sn || (equip && equip.serial_number) || ''
const profileid = opts.profileid != null ? opts.profileid : (equip && equip.fibre_line_profile) || ''
const base = { user: opts.user || 'ops', tel: opts.tel || '' }
let p
if (action === 'replace') p = { old_sn: opts.old_sn || sn, new_sn: opts.new_sn || '', olt, ...base }
else p = { sn, olt, ...base }
if (spec.needs.includes('profileid') || action === 'speed') p.profileid = profileid
if (action === 'activate') { p.profileid = profileid; p.clientid = opts.clientid || (equip && equip.customer) || '' }
if (spec.extra) Object.assign(p, spec.extra)
return p
}
// PLAN — LECTURE SEULE. Résout le contexte, retourne le payload + effets + avertissements. Aucun appel réseau.
async function plan ({ serial, action, ...opts } = {}) {
const spec = ACTIONS[action]
if (!spec) return { ok: false, error: 'action inconnue: ' + action + ' (activate|replace|speed|suspend|unsuspend|remove)' }
const equip = await resolveEquip(opts.old_sn || serial)
const tech = techOf(opts.old_sn || serial, equip)
const payload = buildPayload(action, equip, { ...opts, sn: serial, old_sn: opts.old_sn || serial })
const method = tech === 2 ? 'snmp' : 'n8n' // tech-2 Raisecom = SNMP natif ; tech-3 TP-Link = n8n
const supportsTech = tech != null && Array.isArray(spec.techs) && spec.techs.includes(tech)
const warnings = []
if (!equip) warnings.push('Aucun Service Equipment pour ce numéro de série — lOLT/profil doivent être fournis manuellement.')
if (tech == null) warnings.push('Technologie ONU inconnue (préfixe série non reconnu) — vérifier avant dexécuter.')
else if (!supportsTech) warnings.push('« ' + spec.label + ' » nest pas supporté pour tech-' + tech + (tech === 3 ? ' via ce module (reboot = utiliser TR-069).' : ' (activate/replace/remove Raisecom = script CLI, non branché).'))
if (!payload.olt) warnings.push('IP OLT manquante — requise.')
// tech-2 : besoin des coordonnées ONU (slot/port/ontid) résolues + communauté RW configurée
let snmpReady = true
if (method === 'snmp') {
const haveCoords = equip && equip.olt_slot != null && equip.olt_port != null && equip.olt_ontid != null
if (!haveCoords) { warnings.push('Coordonnées ONU (slot/port/ontid) introuvables sur Service Equipment — requises pour lécriture SNMP.'); snmpReady = false }
if (!OLT_RW_COMMUNITY) { warnings.push('Communauté SNMP en écriture non configurée (env OLT_RW_COMMUNITY) → exécution SNMP désactivée.'); snmpReady = false }
if (action === 'speed' && !(opts.profileid || (equip && equip.fibre_line_profile))) { warnings.push('Profil de ligne (profileid) requis pour le changement de vitesse.'); snmpReady = false }
}
const missing = method === 'n8n' ? spec.needs.filter(k => !payload[k] && payload[k] !== 0) : []
if (missing.length) warnings.push('Champs manquants : ' + missing.join(', '))
const canRun = supportsTech && !!payload.olt && (method === 'n8n' ? (tech === 3 && !missing.length) : snmpReady)
const effects = []
if (action === 'activate') effects.push('Provisionne lONU sur lOLT + met Service Equipment « Actif ».')
if (action === 'replace') effects.push('Ré-authentifie la nouvelle série sur lOLT + met à jour le numéro de série de Service Equipment.')
if (action === 'speed') effects.push('Change le profil de ligne (lONU redémarre).')
if (action === 'suspend') effects.push('Bascule le VLAN internet → coupe laccès (facturation inchangée).')
if (action === 'unsuspend') effects.push('Rétablit le VLAN internet.')
if (action === 'remove') effects.push('Retire lONU de lOLT + délie Service Equipment.')
return {
ok: canRun,
action, label: spec.label, verb: spec.verb, tech, method,
target: { serial: serial || payload.sn, olt: payload.olt, slot: equip && equip.olt_slot, port: equip && equip.olt_port, ontid: equip && equip.olt_ontid, profile: payload.profileid || (equip && equip.fibre_line_profile) || '' },
equipment: equip ? equip.name : null, customer: equip && equip.customer, service_location: equip && equip.service_location,
payload: method === 'n8n' ? payload : { olt: payload.olt, action, snmp: 'MIB 8886 (voir olt-ops.js)' }, effects, warnings,
can_run: canRun,
}
}
// RUN — fire le verbe n8n PUIS miroir ERPNext. Idempotent par clé. confirm:true obligatoire.
async function run ({ serial, action, confirm, idempotencyKey, actor, ...opts } = {}) {
const spec = ACTIONS[action]
if (!spec) return { ok: false, error: 'action inconnue' }
if (!confirm) return { ok: false, error: 'confirm:true requis (action réseau)' }
const pl = await plan({ serial, action, ...opts })
if (!pl.can_run) return { ok: false, error: 'plan non exécutable', plan: pl }
// Idempotence : même clé rejouée dans la fenêtre → renvoyer le résultat mémorisé (NE PAS re-tirer).
const key = String(idempotencyKey || '')
if (!key) return { ok: false, error: 'idempotencyKey requis' }
const store = readStore()
const prev = store[key]
if (prev && (Date.now() - prev.at) < IDEMP_WINDOW_MS) return { ok: true, idempotent: true, ...prev.result }
// FIRE — tech-3 = n8n dostuff ; tech-2 = SNMP set Raisecom (+ save).
let net
if (pl.method === 'snmp') {
net = await snmpRaisecom(action, { host: pl.target.olt, slot: pl.target.slot, port: pl.target.port, ontid: pl.target.ontid, profileid: opts.profileid || (pl.target.profile) })
} else {
net = await dostuff(spec.verb, pl.payload)
}
const result = { action, serial: pl.target.serial, olt: pl.target.olt, method: pl.method, verb: spec.verb, net_ok: !!net.ok, net_error: net.error || null, net_status: net.status, mirrored: [] }
if (net.ok && pl.equipment) {
// Miroir ERPNext (fill-only, best-effort) — reflète l'effet sur Service Equipment.
try {
if (action === 'activate' || action === 'unsuspend') { await erp.update('Service Equipment', pl.equipment, { status: 'Actif' }); result.mirrored.push('status=Actif') }
else if (action === 'suspend') { await erp.update('Service Equipment', pl.equipment, { status: 'Suspendu' }); result.mirrored.push('status=Suspendu') }
else if (action === 'remove') { await erp.update('Service Equipment', pl.equipment, { status: 'Retiré' }); result.mirrored.push('status=Retiré') }
else if (action === 'replace' && opts.new_sn) { await erp.update('Service Equipment', pl.equipment, { serial_number: opts.new_sn }); result.mirrored.push('serial=' + opts.new_sn) }
} catch (e) { result.mirror_error = e.message }
}
store[key] = { at: Date.now(), action, actor: actor || '', result }
writeStore(store)
log('[olt-ops] ' + action + ' ' + pl.target.serial + ' → net_ok=' + result.net_ok + (result.net_error ? ' (' + result.net_error + ')' : '') + ' actor=' + (actor || '?'))
return { ok: !!net.ok, ...result }
}
// G1 reste : clients WiFi connectés d'un ONU (n8n get_wifi_info) — LECTURE.
function wifiClients ({ sn } = {}) {
return new Promise((resolve) => {
if (!sn) return resolve({ ok: false, error: 'sn requis' })
let https; try { https = require('https') } catch (e) { return resolve({ ok: false, error: 'https indisponible' }) }
const body = JSON.stringify({ sn: String(sn) })
const req = https.request('https://n8napi.targo.ca/webhook/get_wifi_info', { method: 'GET', headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) }, timeout: 20000 }, (res) => {
let d = ''; res.on('data', c => { d += c }); res.on('end', () => { let j; try { j = JSON.parse(d) } catch (e) { j = null } resolve({ ok: !!j, clients: Array.isArray(j) ? j : (j && j.clients) || [], raw: j }) })
})
req.on('error', e => resolve({ ok: false, error: e.message }))
req.on('timeout', () => { req.destroy(); resolve({ ok: false, error: 'délai dépassé' }) })
req.write(body); req.end()
})
}
module.exports = { plan, run, wifiClients, ACTIONS }