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>
172 lines
12 KiB
JavaScript
172 lines
12 KiB
JavaScript
'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 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'] },
|
||
}
|
||
|
||
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 — 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.')
|
||
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 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.')
|
||
if (action === 'speed') effects.push('Change le profil de ligne (l’ONU redémarre).')
|
||
if (action === 'suspend') effects.push('Bascule le VLAN internet → coupe l’accès (facturation inchangée).')
|
||
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 },
|
||
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 }
|