gigafibre-fsm/services/targo-hub/lib/olt-ops.js
louispaulb 3d4700decf feat(ops): native Dispatch Job/Communication detail + ONU write parity (Phase 1-2)
Phase 1 (détail natif partout, fini le desk ERPNext) :
- DispatchJobDetail.vue : vue NATIVE du Dispatch Job (assemble les modules partagés : carte/tracé, géofence, photos terrain, durée, compétences, équipe+assign 409-aware, fil+réponse) → SECTION_MAP['Dispatch Job']. Répond à la plainte « le job tombe sur la grille générique + bouton desk ».
- CommunicationDetail.vue : vue NATIVE d'une Communication (courriel/appel) → SECTION_MAP['Communication'] ; HTML assaini.
- 3 pages orphelines (Réseau/GPON, Téléphonie SIP, Flux agent IA) surfacées en nav gatée view_settings ; OCR rattaché à Factures fournisseurs (bouton Numériser) ; stubs « Bientôt » retirés de Rapports.

Phase 2 (parité écriture fibre — remplace le « Do Stuff » de F) :
- lib/olt-ops.js : plan(lecture seule : résout OLT/slot/port + payload n8n exact + effets + avertissements) / run(fire le verbe n8n dostuff PUIS miroir Service Equipment). Idempotent (clé, fenêtre 10 min), confirm obligatoire, tech-3 seulement (tech-2 Raisecom → SNMP natif = G3 à venir). + wifi-clients (G1).
- OnuActionsPanel.vue : suspend/rétablir/forfait/remplacer/retirer/activer en plan→confirm→run, monté dans EquipmentDetail.

Backend déployé+vérifié live (plan/run/guards/auth : plan 200 résout EQP réel, run sans confirm→refus, sans auth→401). SPA déployée /opt/ops-app. AUCUNE écriture réseau réelle tirée (run = à déclencher par un humain via le bouton).

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

172 lines
12 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)
// action → { verb HTTP n8n, champs requis, description FR }
const ACTIONS = {
activate: { verb: 'POST', label: 'Activer / provisionner lONU', needs: ['sn', 'olt', 'profileid'] },
replace: { verb: 'PUT', label: 'Remplacer lONU (é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 linternet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'suspend' } },
unsuspend: { verb: 'PATCH', label: 'Rétablir linternet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'unsuspend' } },
remove: { verb: 'DELETE', label: 'Retirer / désactiver lONU', needs: ['sn', 'olt'] },
}
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(() => [])
return (rows && rows[0]) || null
}
// 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 warnings = []
if (!equip) warnings.push('Aucun Service Equipment pour ce numéro de série — lOLT/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 linstant.')
if (tech == null) warnings.push('Technologie ONU inconnue (préfixe série non reconnu) — vérifier avant dexécuter.')
if (!payload.olt) warnings.push('IP OLT manquante — requise.')
const missing = spec.needs.filter(k => !payload[k] && payload[k] !== 0)
if (missing.length) warnings.push('Champs manquants : ' + missing.join(', '))
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: 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 },
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
}
}
// 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
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: [] }
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 }