diff --git a/apps/ops/src/components/shared/detail-sections/CommunicationDetail.vue b/apps/ops/src/components/shared/detail-sections/CommunicationDetail.vue new file mode 100644 index 0000000..d2d4142 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/CommunicationDetail.vue @@ -0,0 +1,65 @@ + + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/DispatchJobDetail.vue b/apps/ops/src/components/shared/detail-sections/DispatchJobDetail.vue new file mode 100644 index 0000000..b8aab43 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/DispatchJobDetail.vue @@ -0,0 +1,160 @@ + + + + + + diff --git a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue index 239e1c1..7221c9c 100644 --- a/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue +++ b/apps/ops/src/components/shared/detail-sections/EquipmentDetail.vue @@ -448,6 +448,8 @@
Fin garantie{{ doc.warranty_end }}
Adresse{{ doc.service_location }}
Abonnement{{ doc.subscription }}
+ + @@ -507,6 +509,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from 'vue' import { useQuasar } from 'quasar' import InlineField from 'src/components/shared/InlineField.vue' +import OnuActionsPanel from './modules/OnuActionsPanel.vue' import { useDeviceStatus } from 'src/composables/useDeviceStatus' import { useModemDiagnostic } from 'src/composables/useModemDiagnostic' import { deleteDoc } from 'src/api/erp' @@ -826,6 +829,13 @@ function toggleWanLive () { wanLive.value ? stopWanLive() : startWanLive() } // Liste d'appareils au clic sur l'icône : on déplie → la version EN CACHE s'affiche vite (le hub sert le cache par défaut) + anneau de chargement pendant le rafraîchissement. function openHosts () { showHosts.value = true; if (!hosts.value && device.value) loadHosts() } +// Une action ONU a modifié le réseau → refléter le nouveau statut (optimiste) + re-sonder l'appareil. +function onOnuChanged ({ action } = {}) { + const map = { suspend: 'Suspendu', unsuspend: 'Actif', activate: 'Actif', remove: 'Retiré' } + if (map[action]) props.doc.status = map[action] + setTimeout(doRefresh, 1500) +} + async function doRefresh () { if (!props.doc.serial_number) return refreshing.value = true diff --git a/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue b/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue new file mode 100644 index 0000000..3a78b42 --- /dev/null +++ b/apps/ops/src/components/shared/detail-sections/modules/OnuActionsPanel.vue @@ -0,0 +1,144 @@ + + + + + + diff --git a/apps/ops/src/config/nav.js b/apps/ops/src/config/nav.js index a0a350c..aa29bc7 100644 --- a/apps/ops/src/config/nav.js +++ b/apps/ops/src/config/nav.js @@ -23,6 +23,7 @@ export const navItems = [ { path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs', section: 'terrain' }, { path: '/historique', icon: 'History', label: 'Historique', requires: 'view_all_jobs', section: 'terrain' }, { path: '/taches-pilote', icon: 'Workflow', label: 'Orchestration (tâches)', requires: 'view_all_jobs', section: 'terrain' }, + { path: '/network', icon: 'Network', label: 'Réseau (GPON)', requires: 'view_settings', section: 'terrain' }, // ── Administration ── { path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', section: 'admin' }, { path: '/campaigns', icon: 'Gift', label: 'Campagnes', requires: 'manage_users', section: 'admin' }, @@ -31,6 +32,8 @@ export const navItems = [ { path: '/conformite-adresses', icon: 'MapPinned', label: 'Conformité adresses', requires: 'view_settings', section: 'admin' }, { path: '/sync-legacy', icon: 'RefreshCw', label: 'Sync F↔ERPNext', requires: 'view_settings', section: 'admin' }, { path: '/email-queue', icon: 'Mail', label: 'File courriels', requires: 'view_settings', section: 'admin' }, + { path: '/telephony', icon: 'Phone', label: 'Téléphonie (SIP)', requires: 'view_settings', section: 'admin' }, + { path: '/agent-flows', icon: 'Bot', label: 'Flux agent IA', requires: 'view_settings', section: 'admin' }, { path: '/factures-fournisseurs', icon: 'ReceiptText', label: 'Factures fournisseurs', requires: 'view_settings', section: 'admin' }, { path: '/sous-traitants', icon: 'HardHat', label: 'Sous-traitants', requires: 'view_settings', section: 'admin' }, { path: '/facturation/approbations', icon: 'ClipboardCheck', label: 'Approb. facturation', requires: 'view_all_jobs', section: 'admin' }, diff --git a/apps/ops/src/layouts/MainLayout.vue b/apps/ops/src/layouts/MainLayout.vue index 3e74c52..d3b2902 100644 --- a/apps/ops/src/layouts/MainLayout.vue +++ b/apps/ops/src/layouts/MainLayout.vue @@ -199,6 +199,7 @@ import { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper, + Network, Phone, Bot, } from 'lucide-vue-next' // Composants lourds montés conditionnellement (v-if) → chargés à la demande (defineAsyncComponent) au lieu d'alourdir // le chunk MainLayout livré sur CHAQUE page. Aucun accès par ref → sûr. NotificationBell reste synchrone (barre du haut, toujours visible). @@ -217,7 +218,7 @@ const OutboxPanel = defineAsyncComponent(() => import('src/components/shared/Out const CommandPalette = defineAsyncComponent(() => import('src/components/shared/CommandPalette.vue')) import { useCommandPalette } from 'src/composables/useCommandPalette' -const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper } +const icons = { LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3, Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail, CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper, Network, Phone, Bot } const { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations() const { requestCreate } = useCreateSignal() diff --git a/apps/ops/src/pages/RapportsPage.vue b/apps/ops/src/pages/RapportsPage.vue index cf5809a..1e6ec4e 100644 --- a/apps/ops/src/pages/RapportsPage.vue +++ b/apps/ops/src/pages/RapportsPage.vue @@ -15,23 +15,6 @@ - - - - -
-
-
-
- -
{{ report.title }}
- -
-
{{ report.description }}
-
-
-
-
@@ -39,7 +22,6 @@ import { onMounted, onUnmounted } from 'vue' import { useRouter } from 'vue-router' import PageHeader from 'src/components/shared/PageHeader.vue' -import DisclosureSection from 'src/components/shared/DisclosureSection.vue' import { useCommandPalette } from 'src/composables/useCommandPalette' const router = useRouter() @@ -103,44 +85,6 @@ const financeReports = [ }, ] -const opsReports = [ - { - title: 'Clients par territoire', - description: 'Répartition des clients actifs par zone géographique.', - icon: 'map', - color: 'teal-6', - action: () => {}, - }, - { - title: 'Tickets par priorité', - description: 'Analyse des tickets ouverts et temps de résolution moyen.', - icon: 'bug_report', - color: 'orange-6', - action: () => {}, - }, - { - title: 'Équipements déployés', - description: 'Inventaire des ONT, routeurs et modems par statut.', - icon: 'router', - color: 'blue-6', - action: () => {}, - }, - { - title: 'Abonnements actifs', - description: 'Liste des abonnements avec plan, montant et ancienneté.', - icon: 'subscriptions', - color: 'purple-6', - action: () => {}, - }, - { - title: 'Dispatch performance', - description: 'Taux de complétion des tâches et temps moyen par technicien.', - icon: 'speed', - color: 'red-6', - action: () => {}, - }, -] - // Chaque rapport devient joignable par la palette (⌘K) — l'index visuel reste épuré, // mais rien n'est « caché » : taper le nom d'un rapport l'ouvre directement. const { registerActions, unregisterActions } = useCommandPalette() diff --git a/apps/ops/src/pages/SupplierInvoicesPage.vue b/apps/ops/src/pages/SupplierInvoicesPage.vue index ac95e3b..9d5a902 100644 --- a/apps/ops/src/pages/SupplierInvoicesPage.vue +++ b/apps/ops/src/pages/SupplierInvoicesPage.vue @@ -8,6 +8,8 @@ + + diff --git a/services/targo-hub/lib/olt-ops.js b/services/targo-hub/lib/olt-ops.js new file mode 100644 index 0000000..3603f6b --- /dev/null +++ b/services/targo-hub/lib/olt-ops.js @@ -0,0 +1,171 @@ +'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 }