From 23dde4ecf0e966b078caac4aaa5daa2d4d5194e2 Mon Sep 17 00:00:00 2001 From: louispaulb Date: Sat, 18 Jul 2026 22:39:59 -0400 Subject: [PATCH] feat(ops): native Payment Method/Arrangement + VoIP Line sections + G3 Raisecom SNMP writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../PaymentArrangementDetail.vue | 69 +++++++++++ .../detail-sections/PaymentMethodDetail.vue | 56 +++++++++ .../shared/detail-sections/VoipLineDetail.vue | 56 +++++++++ .../modules/OnuActionsPanel.vue | 1 + services/targo-hub/lib/olt-ops.js | 115 +++++++++++++++--- 5 files changed, 279 insertions(+), 18 deletions(-) create mode 100644 apps/ops/src/components/shared/detail-sections/PaymentArrangementDetail.vue create mode 100644 apps/ops/src/components/shared/detail-sections/PaymentMethodDetail.vue create mode 100644 apps/ops/src/components/shared/detail-sections/VoipLineDetail.vue diff --git a/apps/ops/src/components/shared/detail-sections/PaymentArrangementDetail.vue b/apps/ops/src/components/shared/detail-sections/PaymentArrangementDetail.vue new file mode 100644 index 0000000..0f6f618 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/PaymentArrangementDetail.vue @@ -0,0 +1,69 @@ + + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/PaymentMethodDetail.vue b/apps/ops/src/components/shared/detail-sections/PaymentMethodDetail.vue new file mode 100644 index 0000000..a7cea8d --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/PaymentMethodDetail.vue @@ -0,0 +1,56 @@ + + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/VoipLineDetail.vue b/apps/ops/src/components/shared/detail-sections/VoipLineDetail.vue new file mode 100644 index 0000000..457e8d1 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/VoipLineDetail.vue @@ -0,0 +1,56 @@ + + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue b/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue index 3a78b42..10a6124 100644 --- a/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue +++ b/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue @@ -74,6 +74,7 @@ const emit = defineEmits(['changed']) const menu = [ { action: 'suspend', label: 'Suspendre', icon: 'pause_circle', color: 'orange-8' }, { action: 'unsuspend', label: 'Rétablir', icon: 'play_circle', color: 'green-7' }, + { action: 'reboot', label: 'Redémarrer', icon: 'restart_alt', color: 'blue-grey-7' }, { action: 'speed', label: 'Forfait / vitesse', icon: 'speed', color: 'blue-7' }, { action: 'replace', label: 'Remplacer', icon: 'swap_horiz', color: 'deep-purple-6' }, { action: 'remove', label: 'Retirer', icon: 'link_off', color: 'red-7' }, diff --git a/services/targo-hub/lib/olt-ops.js b/services/targo-hub/lib/olt-ops.js index 3603f6b..b98cf95 100644 --- a/services/targo-hub/lib/olt-ops.js +++ b/services/targo-hub/lib/olt-ops.js @@ -20,15 +20,61 @@ 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' -// action → { verb HTTP n8n, champs requis, description FR } +// 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 l’ONU', needs: ['sn', 'olt', 'profileid'] }, - replace: { verb: 'PUT', label: 'Remplacer l’ONU (échange de série)', needs: ['old_sn', 'new_sn', 'olt'] }, - speed: { verb: 'PATCH', label: 'Changer le forfait / la vitesse', needs: ['sn', 'olt', 'profileid'], extra: { trigger: 'speedchange' } }, - suspend: { verb: 'PATCH', label: 'Suspendre l’internet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'suspend' } }, - unsuspend: { verb: 'PATCH', label: 'Rétablir l’internet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'unsuspend' } }, - remove: { verb: 'DELETE', label: 'Retirer / désactiver l’ONU', needs: ['sn', 'olt'] }, + activate: { verb: 'POST', label: 'Activer / provisionner l’ONU', needs: ['sn', 'olt', 'profileid'], techs: [3] }, + replace: { verb: 'PUT', label: 'Remplacer l’ONU (é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 l’internet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'suspend' }, techs: [3, 2] }, + unsuspend: { verb: 'PATCH', label: 'Rétablir l’internet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'unsuspend' }, techs: [3, 2] }, + remove: { verb: 'DELETE', label: 'Retirer / désactiver l’ONU', needs: ['sn', 'olt'], techs: [3] }, + reboot: { verb: null, label: 'Redémarrer l’ONU', 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 {} } } @@ -53,7 +99,24 @@ async function resolveEquip (serial) { fields: ['name', 'serial_number', 'mac_address', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'customer', 'service_location', 'status', 'equipment_type'], limit: 1, }).catch(() => []) - return (rows && rows[0]) || null + 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). @@ -98,13 +161,24 @@ async function plan ({ serial, action, ...opts } = {}) { 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 — l’OLT/profil doivent être fournis manuellement.') - if (tech === 2) warnings.push('ONU Raisecom (tech-2) : les écritures passent par SNMP direct (côté F), PAS par n8n. Écriture native SNMP non encore branchée (G3) → utiliser F pour l’instant.') if (tech == null) warnings.push('Technologie ONU inconnue (préfixe série non reconnu) — vérifier avant d’exécuter.') + else if (!supportsTech) warnings.push('« ' + spec.label + ' » n’est 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.') - const missing = spec.needs.filter(k => !payload[k] && payload[k] !== 0) + // 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 l’ONU sur l’OLT + met Service Equipment « Actif ».') if (action === 'replace') effects.push('Ré-authentifie la nouvelle série sur l’OLT + met à jour le numéro de série de Service Equipment.') @@ -113,12 +187,12 @@ async function plan ({ serial, action, ...opts } = {}) { if (action === 'unsuspend') effects.push('Rétablit le VLAN internet.') if (action === 'remove') effects.push('Retire l’ONU de l’OLT + délie Service Equipment.') return { - ok: warnings.length === 0 || (tech === 3 && !missing.length && !!payload.olt), - action, label: spec.label, verb: spec.verb, tech, - 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 }, + 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, effects, warnings, - can_run: tech === 3 && !missing.length && !!payload.olt, // n8n = tech-3 seulement + payload: method === 'n8n' ? payload : { olt: payload.olt, action, snmp: 'MIB 8886 (voir olt-ops.js)' }, effects, warnings, + can_run: canRun, } } @@ -135,9 +209,14 @@ async function run ({ serial, action, confirm, idempotencyKey, actor, ...opts } const store = readStore() const prev = store[key] if (prev && (Date.now() - prev.at) < IDEMP_WINDOW_MS) return { ok: true, idempotent: true, ...prev.result } - // FIRE - const net = await dostuff(spec.verb, pl.payload) - const result = { action, serial: pl.target.serial, olt: pl.payload.olt, verb: spec.verb, net_ok: !!net.ok, net_error: net.error || null, net_status: net.status, mirrored: [] } + // 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 {