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>
This commit is contained in:
parent
cd25fa2504
commit
3d4700decf
|
|
@ -0,0 +1,65 @@
|
||||||
|
<!--
|
||||||
|
CommunicationDetail — vue NATIVE d'une Communication (courriel/appel/note) dans DetailModal.
|
||||||
|
Ouverte depuis la timeline de la fiche client ; remplace la grille générique + bouton desk.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="cd-head">
|
||||||
|
<q-icon :name="typeIcon" size="18px" class="q-mr-xs" :color="typeColor" />
|
||||||
|
<span class="cd-type">{{ typeLabel }}</span>
|
||||||
|
<span v-if="doc.communication_date" class="cd-date">{{ formatDateTime(doc.communication_date) }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="doc.subject" class="cd-subject">{{ doc.subject }}</div>
|
||||||
|
|
||||||
|
<div class="cd-grid">
|
||||||
|
<div v-if="doc.sender" class="cd-row"><span class="cd-k">De</span><span>{{ doc.sender }}</span></div>
|
||||||
|
<div v-if="doc.recipients" class="cd-row"><span class="cd-k">À</span><span>{{ doc.recipients }}</span></div>
|
||||||
|
<div v-if="doc.cc" class="cd-row"><span class="cd-k">CC</span><span>{{ doc.cc }}</span></div>
|
||||||
|
<div v-if="doc.reference_name" class="cd-row"><span class="cd-k">Lié à</span>
|
||||||
|
<a class="erp-link" @click="$emit('navigate', doc.reference_doctype, doc.reference_name, doc.reference_name)">{{ doc.reference_doctype }} · {{ doc.reference_name }}</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-sm" />
|
||||||
|
|
||||||
|
<!-- Contenu (HTML nettoyé) ou texte brut -->
|
||||||
|
<div v-if="doc.content" class="cd-body" v-html="safeContent" />
|
||||||
|
<div v-else class="text-grey-6 q-pa-md text-center">Aucun contenu.</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { formatDateTime } from 'src/composables/useFormatters'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
doc: { type: Object, required: true },
|
||||||
|
docName: { type: String, default: '' },
|
||||||
|
})
|
||||||
|
defineEmits(['navigate'])
|
||||||
|
|
||||||
|
const typeLabel = computed(() => ({ Communication: 'Courriel', Comment: 'Note', Chat: 'Clavardage', Notification: 'Notification', Feedback: 'Retour' })[props.doc.communication_medium] || ({ Email: 'Courriel', Phone: 'Appel', SMS: 'SMS', Chat: 'Clavardage' })[props.doc.communication_medium] || props.doc.communication_medium || 'Communication')
|
||||||
|
const typeIcon = computed(() => ({ Email: 'mail', Phone: 'call', SMS: 'sms', Chat: 'chat' })[props.doc.communication_medium] || 'forum')
|
||||||
|
const typeColor = computed(() => ({ Email: 'blue-7', Phone: 'green-7', SMS: 'teal-7' })[props.doc.communication_medium] || 'grey-7')
|
||||||
|
|
||||||
|
// Le contenu est déjà du HTML côté ERPNext ; on retire seulement les balises dangereuses (script/iframe/on*).
|
||||||
|
const safeContent = computed(() => String(props.doc.content || '')
|
||||||
|
.replace(/<\s*(script|iframe|object|embed|style)\b[^>]*>[\s\S]*?<\s*\/\s*\1\s*>/gi, '')
|
||||||
|
.replace(/\son\w+\s*=\s*("[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
||||||
|
.replace(/javascript:/gi, ''))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.cd-head { display: flex; align-items: center; gap: 6px; font-size: 12.5px; color: #607d8b; }
|
||||||
|
.cd-type { font-weight: 700; }
|
||||||
|
.cd-date { margin-left: auto; color: #90a4ae; }
|
||||||
|
.cd-subject { font-size: 15px; font-weight: 600; margin: 6px 0; }
|
||||||
|
.cd-grid { display: flex; flex-direction: column; gap: 3px; font-size: 12.5px; }
|
||||||
|
.cd-row { display: flex; gap: 8px; }
|
||||||
|
.cd-k { min-width: 44px; color: #90a4ae; text-transform: uppercase; font-size: 10px; letter-spacing: .05em; padding-top: 2px; }
|
||||||
|
.cd-body { font-size: 13px; line-height: 1.5; overflow-x: auto; }
|
||||||
|
.cd-body :deep(img) { max-width: 100%; height: auto; }
|
||||||
|
.cd-body :deep(table) { max-width: 100%; }
|
||||||
|
.cd-body :deep(blockquote) { border-left: 3px solid #e0e6ef; margin: 6px 0; padding-left: 10px; color: #78909c; }
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,160 @@
|
||||||
|
<!--
|
||||||
|
DispatchJobDetail — vue NATIVE d'un Dispatch Job dans DetailModal (SECTION_MAP['Dispatch Job']).
|
||||||
|
AVANT : ouvrir un job (ex. timeline fiche client) tombait sur la grille générique + bouton desk ERPNext.
|
||||||
|
MAINTENANT : même chrome moderne que le ticket, en assemblant les MÊMES modules partagés que le volet
|
||||||
|
Planification et l'onglet « Intervention sur site » d'IssueDetail — zéro duplication.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Client + intitulé -->
|
||||||
|
<div v-if="!hideCustomerLink && doc.customer" class="dj-cust">
|
||||||
|
<q-icon name="person" size="16px" class="q-mr-xs" />
|
||||||
|
<router-link :to="'/clients/' + encodeURIComponent(doc.customer)" class="erp-link">{{ doc.customer_name || doc.customer }}</router-link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Statut + priorité + planif -->
|
||||||
|
<div class="dj-meta">
|
||||||
|
<q-select dense options-dense outlined v-model="statusModel" :options="STATUS_OPTS" emit-value map-options
|
||||||
|
label="Statut" style="min-width:150px" @update:model-value="onStatus" :loading="savingStatus" />
|
||||||
|
<span v-if="doc.priority" class="dj-chip" :class="'dj-prio-' + String(doc.priority).toLowerCase()">{{ prioLabel }}</span>
|
||||||
|
<span v-if="scheduledLabel" class="dj-when"><q-icon name="event" size="15px" /> {{ scheduledLabel }}</span>
|
||||||
|
<span v-if="doc.job_type" class="dj-chip dj-type">{{ doc.job_type }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="doc.address" class="dj-addr"><q-icon name="place" size="15px" /> {{ doc.address }}</div>
|
||||||
|
<div v-if="doc.legacy_ticket_id" class="dj-legacy">Ticket F #{{ doc.legacy_ticket_id }}</div>
|
||||||
|
|
||||||
|
<q-separator class="q-my-sm" />
|
||||||
|
|
||||||
|
<!-- Carte + tracé GPS -->
|
||||||
|
<JobMapModule :name="doc.name || docName" :lat="doc.latitude" :lon="doc.longitude"
|
||||||
|
:tech-id="doc.assigned_tech || ''" :iso="iso" :can-track="canTrack" :today-iso="todayIso" />
|
||||||
|
<!-- Suivi terrain (géofencing) -->
|
||||||
|
<GeofenceTimeline :name="doc.name || docName" />
|
||||||
|
<!-- Photos du tech (preuve d'adresse géo-vérifiée + appareil) + journal terrain -->
|
||||||
|
<JobMediaModule :name="doc.name || docName" />
|
||||||
|
<!-- Durée estimée -->
|
||||||
|
<DurationField :name="doc.name || docName" :dur-h="Number(doc.duration_h) || 1" @updated="p => { doc.duration_h = p.duration_h }" />
|
||||||
|
<!-- Compétences requises -->
|
||||||
|
<RequiredSkillsField :name="doc.name || docName" :skills="skills" :all-tags="allTags" :get-color="getTagColor"
|
||||||
|
@updated="arr => { doc.required_skill = (arr && arr[0]) || '' }" @create="onCreateTag" />
|
||||||
|
<!-- Équipe / assignation (lead + assistants) — chemin roster simple (avec garde-fou 409 F) -->
|
||||||
|
<div class="q-mt-sm">
|
||||||
|
<JobTeamField :name="doc.name || docName" :tech-id="doc.assigned_tech || ''" :tech-name="techName"
|
||||||
|
:tech-options="techOptions" :can-onsite="true"
|
||||||
|
@assign-lead="onAssignLead" @unassign-lead="onUnassignLead" @updated="() => $emit('dispatch-updated', doc.name || docName)" />
|
||||||
|
</div>
|
||||||
|
<!-- Fil du billet + réponse (si lié à un ticket F) -->
|
||||||
|
<template v-if="doc.legacy_ticket_id">
|
||||||
|
<JobThread ref="threadRef" :lid="doc.legacy_ticket_id" />
|
||||||
|
<JobReplyBox :name="doc.name || docName" :lid="doc.legacy_ticket_id" @sent="() => { threadRef && threadRef.reload(); $emit('reply-sent', doc.name || docName) }" />
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { updateDoc } from 'src/api/erp'
|
||||||
|
import { useTagCatalog } from 'src/composables/useTagCatalog'
|
||||||
|
import * as roster from 'src/api/roster'
|
||||||
|
import JobMapModule from './modules/JobMapModule.vue'
|
||||||
|
import GeofenceTimeline from './modules/GeofenceTimeline.vue'
|
||||||
|
import JobMediaModule from './modules/JobMediaModule.vue'
|
||||||
|
import DurationField from './modules/DurationField.vue'
|
||||||
|
import RequiredSkillsField from './modules/RequiredSkillsField.vue'
|
||||||
|
import JobTeamField from './modules/JobTeamField.vue'
|
||||||
|
import JobThread from './modules/JobThread.vue'
|
||||||
|
import JobReplyBox from './modules/JobReplyBox.vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
doc: { type: Object, required: true },
|
||||||
|
docName: { type: String, default: '' },
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
hideCustomerLink: { type: Boolean, default: false },
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['navigate', 'dispatch-updated', 'reply-sent'])
|
||||||
|
|
||||||
|
const STATUS_OPTS = [
|
||||||
|
{ label: 'À planifier', value: 'open' },
|
||||||
|
{ label: 'Assigné', value: 'assigned' },
|
||||||
|
{ label: 'En cours', value: 'In Progress' },
|
||||||
|
{ label: 'En attente', value: 'On Hold' },
|
||||||
|
{ label: 'Complété', value: 'Completed' },
|
||||||
|
{ label: 'Annulé', value: 'Cancelled' },
|
||||||
|
]
|
||||||
|
const statusModel = ref(props.doc.status || 'open')
|
||||||
|
const savingStatus = ref(false)
|
||||||
|
async function onStatus (v) {
|
||||||
|
const prev = props.doc.status
|
||||||
|
savingStatus.value = true
|
||||||
|
try {
|
||||||
|
await updateDoc('Dispatch Job', props.doc.name || props.docName, { status: v })
|
||||||
|
props.doc.status = v
|
||||||
|
Notify.create({ type: 'positive', message: 'Statut : ' + (STATUS_OPTS.find(o => o.value === v) || {}).label, timeout: 1400 })
|
||||||
|
emit('dispatch-updated', props.doc.name || props.docName, { status: v })
|
||||||
|
} catch (e) { statusModel.value = prev; Notify.create({ type: 'negative', message: 'Statut : ' + (e.message || e), timeout: 3000 }) } finally { savingStatus.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
const prioLabel = computed(() => ({ high: 'Urgent', medium: 'Moyenne', low: 'Basse' })[String(props.doc.priority || '').toLowerCase()] || props.doc.priority)
|
||||||
|
const scheduledLabel = computed(() => {
|
||||||
|
const d = props.doc.scheduled_date; if (!d) return ''
|
||||||
|
const t = props.doc.start_time ? String(props.doc.start_time).slice(0, 5) : ''
|
||||||
|
return d + (t ? ' · ' + t : '')
|
||||||
|
})
|
||||||
|
const iso = computed(() => String(props.doc.scheduled_date || '').slice(0, 10))
|
||||||
|
const todayIso = computed(() => new Date().toLocaleDateString('en-CA', { timeZone: 'America/Toronto' }))
|
||||||
|
const canTrack = computed(() => {
|
||||||
|
const i = iso.value; if (!i) return false
|
||||||
|
const n = todayIso.value
|
||||||
|
const yest = new Date(Date.parse(n + 'T12:00:00Z') - 86400000).toISOString().slice(0, 10)
|
||||||
|
return i === n || i === yest
|
||||||
|
})
|
||||||
|
const skills = computed(() => { const s = props.doc.required_skill; return s ? [s] : [] })
|
||||||
|
|
||||||
|
// Tag catalog (source unique) pour les compétences requises.
|
||||||
|
const { allTags, getColor: getTagColor, load: loadTagCatalog, createTag: createCatTag } = useTagCatalog()
|
||||||
|
async function onCreateTag ({ label, color }) { try { await createCatTag({ label, color }) } catch (e) { /* best-effort */ } }
|
||||||
|
|
||||||
|
// Techs (chargés à l'ouverture) pour l'assignation lead/équipe.
|
||||||
|
const jobTechs = ref([])
|
||||||
|
const techOptions = computed(() => (jobTechs.value || []).map(t => ({ label: t.name + ((t.skills || []).length ? ' · ' + t.skills.slice(0, 4).join(', ') : ''), value: t.id })))
|
||||||
|
const techName = computed(() => { const id = props.doc.assigned_tech; if (!id) return ''; const t = (jobTechs.value || []).find(x => x.id === id); return (t && t.name) || String(id) })
|
||||||
|
const threadRef = ref(null)
|
||||||
|
|
||||||
|
async function onAssignLead (techId) {
|
||||||
|
const name = props.doc.name || props.docName; if (!techId || !name) return
|
||||||
|
try {
|
||||||
|
await roster.assignJob(name, techId, iso.value || undefined) // 409-aware (garde F) via api/roster
|
||||||
|
props.doc.assigned_tech = techId
|
||||||
|
const t = (jobTechs.value || []).find(x => x.id === techId)
|
||||||
|
Notify.create({ type: 'positive', message: 'Assigné à ' + ((t && t.name) || techId), timeout: 1800 })
|
||||||
|
emit('dispatch-updated', name, { assigned_tech: techId })
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: e.message || String(e), timeout: 3000 }) }
|
||||||
|
}
|
||||||
|
async function onUnassignLead () {
|
||||||
|
const name = props.doc.name || props.docName; if (!name) return
|
||||||
|
try {
|
||||||
|
await roster.unassignJobRoster(name); props.doc.assigned_tech = ''
|
||||||
|
Notify.create({ type: 'info', message: 'Renvoyé au pool', timeout: 1600 })
|
||||||
|
emit('dispatch-updated', name, { assigned_tech: '' })
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: e.message || String(e), timeout: 3000 }) }
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
loadTagCatalog()
|
||||||
|
try { const r = await roster.listTechnicians(); jobTechs.value = Array.isArray(r) ? r : ((r && r.technicians) || []) } catch { jobTechs.value = [] }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.dj-cust { font-size: 13.5px; margin-bottom: 8px; }
|
||||||
|
.dj-meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||||
|
.dj-when { font-size: 12.5px; color: #455a64; }
|
||||||
|
.dj-addr { font-size: 12.5px; color: #546e7a; margin-bottom: 4px; }
|
||||||
|
.dj-legacy { font-size: 11px; color: #90a4ae; }
|
||||||
|
.dj-chip { font-size: 11px; font-weight: 700; border-radius: 999px; padding: 2px 9px; background: #eceff1; color: #455a64; }
|
||||||
|
.dj-type { background: #e8eaf6; color: #3949ab; }
|
||||||
|
.dj-prio-high { background: #fbe7e7; color: #b83232; }
|
||||||
|
.dj-prio-medium { background: #fbf0dc; color: #996515; }
|
||||||
|
.dj-prio-low { background: #eceff1; color: #607d8b; }
|
||||||
|
</style>
|
||||||
|
|
@ -448,6 +448,8 @@
|
||||||
<div class="mf" v-if="doc.warranty_end"><span class="mf-label">Fin garantie</span>{{ doc.warranty_end }}</div>
|
<div class="mf" v-if="doc.warranty_end"><span class="mf-label">Fin garantie</span>{{ doc.warranty_end }}</div>
|
||||||
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
<div class="mf" v-if="doc.service_location"><span class="mf-label">Adresse</span>{{ doc.service_location }}</div>
|
||||||
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
|
<div class="mf" v-if="doc.subscription"><span class="mf-label">Abonnement</span>{{ doc.subscription }}</div>
|
||||||
|
<!-- Actions cycle-de-vie ONU (plan→confirm→run, tech-3 via n8n) — remplace le « Do Stuff » de F. -->
|
||||||
|
<OnuActionsPanel v-if="doc.serial_number && doc.olt_ip" :serial="doc.serial_number" :olt="doc.olt_ip" :status="doc.status" @changed="onOnuChanged" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Port Neighbor Context — shows health of the same OLT port -->
|
<!-- Port Neighbor Context — shows health of the same OLT port -->
|
||||||
|
|
@ -507,6 +509,7 @@
|
||||||
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import InlineField from 'src/components/shared/InlineField.vue'
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
|
import OnuActionsPanel from './modules/OnuActionsPanel.vue'
|
||||||
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||||||
import { useModemDiagnostic } from 'src/composables/useModemDiagnostic'
|
import { useModemDiagnostic } from 'src/composables/useModemDiagnostic'
|
||||||
import { deleteDoc } from 'src/api/erp'
|
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.
|
// 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() }
|
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 () {
|
async function doRefresh () {
|
||||||
if (!props.doc.serial_number) return
|
if (!props.doc.serial_number) return
|
||||||
refreshing.value = true
|
refreshing.value = true
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
<!--
|
||||||
|
OnuActionsPanel — actions cycle-de-vie ONU (fibre TP-Link tech-3) depuis la fiche appareil.
|
||||||
|
Modèle plan → confirm → run : chaque action AFFICHE d'abord l'aperçu (hub /olt/onu/plan, LECTURE SEULE :
|
||||||
|
OLT/slot/port résolus, payload n8n exact, effets, avertissements), et n'EXÉCUTE (/olt/onu/run) qu'à la
|
||||||
|
confirmation, avec une clé d'idempotence (re-clic = pas de 2e tir). Réservé tech-3 (n8n) ; tech-2 (Raisecom)
|
||||||
|
→ le plan indique « SNMP direct côté F » et bloque l'exécution. Autonome : n'appelle que le hub.
|
||||||
|
-->
|
||||||
|
<template>
|
||||||
|
<div v-if="serial" class="q-mt-md">
|
||||||
|
<div class="info-block-title"><q-icon name="settings_input_antenna" size="16px" class="q-mr-xs" />Actions ONU (fibre)</div>
|
||||||
|
<div class="text-caption text-grey-6 q-mb-xs">Chaque action montre un aperçu avant d’exécuter. Modifie le réseau du client.</div>
|
||||||
|
<div class="row q-gutter-xs">
|
||||||
|
<q-btn v-for="a in menu" :key="a.action" outline dense no-caps size="sm" :color="a.color || 'grey-8'"
|
||||||
|
:icon="a.icon" :label="a.label" @click="openAction(a.action)" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-dialog v-model="dlg">
|
||||||
|
<q-card style="min-width:340px;max-width:94vw">
|
||||||
|
<q-card-section class="row items-center q-pb-xs">
|
||||||
|
<div class="text-subtitle1 text-weight-bold">{{ planData ? planData.label : 'Action ONU' }}</div>
|
||||||
|
<q-space />
|
||||||
|
<q-badge v-if="planData && planData.tech" :color="planData.tech === 3 ? 'teal-6' : 'orange-7'">tech-{{ planData.tech }}</q-badge>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section class="q-gutter-sm">
|
||||||
|
<!-- Entrées propres à certaines actions -->
|
||||||
|
<q-input v-if="curAction === 'replace'" v-model="newSn" dense outlined label="Nouvelle série (new_sn)" @update:model-value="loadPlan" />
|
||||||
|
<q-input v-if="curAction === 'speed' || curAction === 'activate'" v-model="profileId" dense outlined label="Profil de ligne (profileid)" @update:model-value="loadPlan" />
|
||||||
|
|
||||||
|
<div v-if="planLoading" class="text-center q-pa-md"><q-spinner size="26px" color="primary" /></div>
|
||||||
|
<template v-else-if="planData">
|
||||||
|
<!-- Cible -->
|
||||||
|
<div class="oa-target">
|
||||||
|
<div><span class="oa-k">Série</span>{{ planData.target.serial || '—' }}</div>
|
||||||
|
<div><span class="oa-k">OLT</span>{{ planData.target.olt || '—' }}<span v-if="planData.target.slot != null"> · {{ planData.target.slot }}/{{ planData.target.port }}/{{ planData.target.ontid }}</span></div>
|
||||||
|
<div v-if="planData.customer"><span class="oa-k">Client</span>{{ planData.customer }}</div>
|
||||||
|
<div><span class="oa-k">Appel</span><code>{{ planData.verb }} dostuff</code></div>
|
||||||
|
</div>
|
||||||
|
<!-- Effets -->
|
||||||
|
<ul v-if="planData.effects && planData.effects.length" class="oa-effects">
|
||||||
|
<li v-for="(e, i) in planData.effects" :key="i">{{ e }}</li>
|
||||||
|
</ul>
|
||||||
|
<!-- Avertissements -->
|
||||||
|
<q-banner v-if="planData.warnings && planData.warnings.length" dense class="bg-orange-1 text-orange-9 oa-warn">
|
||||||
|
<template #avatar><q-icon name="warning" color="orange-8" /></template>
|
||||||
|
<div v-for="(w, i) in planData.warnings" :key="i" class="oa-warn-line">{{ w }}</div>
|
||||||
|
</q-banner>
|
||||||
|
</template>
|
||||||
|
<div v-else class="text-negative text-caption">{{ planError || 'Aperçu indisponible.' }}</div>
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat no-caps label="Annuler" color="grey-8" v-close-popup :disable="running" />
|
||||||
|
<q-btn unelevated no-caps :label="'Exécuter — ' + (planData ? planData.label : '')" color="negative"
|
||||||
|
:loading="running" :disable="!planData || !planData.can_run" @click="doRun" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import { HUB_URL } from 'src/config/hub'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
serial: { type: String, default: '' },
|
||||||
|
olt: { type: String, default: '' },
|
||||||
|
status: { type: String, default: '' }, // statut courant (Actif/Suspendu…) pour proposer suspend vs unsuspend
|
||||||
|
})
|
||||||
|
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: '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' },
|
||||||
|
{ action: 'activate', label: 'Activer', icon: 'power', color: 'teal-7' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const dlg = ref(false)
|
||||||
|
const curAction = ref('')
|
||||||
|
const newSn = ref('')
|
||||||
|
const profileId = ref('')
|
||||||
|
const planData = ref(null)
|
||||||
|
const planLoading = ref(false)
|
||||||
|
const planError = ref('')
|
||||||
|
const running = ref(false)
|
||||||
|
|
||||||
|
function openAction (action) {
|
||||||
|
curAction.value = action
|
||||||
|
newSn.value = ''
|
||||||
|
profileId.value = ''
|
||||||
|
planData.value = null
|
||||||
|
planError.value = ''
|
||||||
|
dlg.value = true
|
||||||
|
loadPlan()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlan () {
|
||||||
|
planLoading.value = true; planError.value = ''
|
||||||
|
try {
|
||||||
|
const q = new URLSearchParams({ serial: props.serial, action: curAction.value })
|
||||||
|
if (props.olt) q.set('olt', props.olt)
|
||||||
|
if (newSn.value) q.set('new_sn', newSn.value)
|
||||||
|
if (profileId.value) q.set('profileid', profileId.value)
|
||||||
|
const r = await fetch(HUB_URL + '/olt/onu/plan?' + q.toString())
|
||||||
|
if (!r.ok) throw new Error('plan ' + r.status)
|
||||||
|
planData.value = await r.json()
|
||||||
|
} catch (e) { planError.value = e.message || String(e); planData.value = null } finally { planLoading.value = false }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRun () {
|
||||||
|
if (!planData.value || !planData.value.can_run) return
|
||||||
|
running.value = true
|
||||||
|
try {
|
||||||
|
// Clé d'idempotence par action confirmée (re-clic accidentel = pas de 2e tir côté hub, fenêtre 10 min).
|
||||||
|
const key = (crypto.randomUUID ? crypto.randomUUID() : (Date.now() + '-' + Math.round(Math.random() * 1e9)))
|
||||||
|
const body = { serial: props.serial, action: curAction.value, confirm: true, idempotencyKey: key }
|
||||||
|
if (props.olt) body.olt = props.olt
|
||||||
|
if (newSn.value) body.new_sn = newSn.value
|
||||||
|
if (profileId.value) body.profileid = profileId.value
|
||||||
|
const r = await fetch(HUB_URL + '/olt/onu/run', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) })
|
||||||
|
const d = await r.json()
|
||||||
|
if (d.ok) {
|
||||||
|
Notify.create({ type: 'positive', message: (planData.value.label || 'Action') + ' — exécuté' + (d.idempotent ? ' (déjà fait)' : '') + (d.mirrored && d.mirrored.length ? ' · ' + d.mirrored.join(', ') : ''), timeout: 4000 })
|
||||||
|
dlg.value = false
|
||||||
|
emit('changed', { action: curAction.value, result: d })
|
||||||
|
} else {
|
||||||
|
Notify.create({ type: 'negative', message: (planData.value.label || 'Action') + ' — échec : ' + (d.net_error || d.error || 'inconnu'), timeout: 6000 })
|
||||||
|
}
|
||||||
|
} catch (e) { Notify.create({ type: 'negative', message: 'Erreur : ' + (e.message || e), timeout: 5000 }) } finally { running.value = false }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.oa-target { font-size: 12.5px; display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.oa-target .oa-k { display: inline-block; min-width: 52px; color: #90a4ae; text-transform: uppercase; font-size: 10px; letter-spacing: .05em; }
|
||||||
|
.oa-effects { margin: 6px 0 0; padding-left: 18px; font-size: 12.5px; color: #37474f; }
|
||||||
|
.oa-warn { border-radius: 8px; }
|
||||||
|
.oa-warn-line { font-size: 12px; }
|
||||||
|
</style>
|
||||||
|
|
@ -23,6 +23,7 @@ export const navItems = [
|
||||||
{ path: '/copilote', icon: 'Sparkles', label: 'Copilote', requires: 'view_all_jobs', section: 'terrain' },
|
{ 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: '/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: '/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 ──
|
// ── Administration ──
|
||||||
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', section: 'admin' },
|
{ path: '/equipe', icon: 'UsersRound', label: 'Équipe', requires: 'manage_users', section: 'admin' },
|
||||||
{ path: '/campaigns', icon: 'Gift', label: 'Campagnes', 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: '/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: '/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: '/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: '/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: '/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' },
|
{ path: '/facturation/approbations', icon: 'ClipboardCheck', label: 'Approb. facturation', requires: 'view_all_jobs', section: 'admin' },
|
||||||
|
|
|
||||||
|
|
@ -199,6 +199,7 @@ import {
|
||||||
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
LayoutDashboard, Users, Truck, Ticket, UsersRound, BarChart3,
|
||||||
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
Gift, Settings, LogOut, PanelLeftOpen, PanelLeftClose, Mail,
|
||||||
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper,
|
CalendarRange, CalendarClock, Sparkles, MapPinned, Workflow, RefreshCw, ReceiptText, MessagesSquare, History, Award, Star, ClipboardCheck, HardHat, PartyPopper,
|
||||||
|
Network, Phone, Bot,
|
||||||
} from 'lucide-vue-next'
|
} from 'lucide-vue-next'
|
||||||
// Composants lourds montés conditionnellement (v-if) → chargés à la demande (defineAsyncComponent) au lieu d'alourdir
|
// 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).
|
// 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'))
|
const CommandPalette = defineAsyncComponent(() => import('src/components/shared/CommandPalette.vue'))
|
||||||
import { useCommandPalette } from 'src/composables/useCommandPalette'
|
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 { panelOpen, newDialogOpen, newDialogChannel, activeCount: convCount, openComposeDraft } = useConversations()
|
||||||
const { requestCreate } = useCreateSignal()
|
const { requestCreate } = useCreateSignal()
|
||||||
|
|
|
||||||
|
|
@ -15,23 +15,6 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Opérations : rapports pas encore prêts → repliés par défaut (ne polluent pas la vue). -->
|
|
||||||
<DisclosureSection title="Opérations" icon="insights" persist-key="rapports.ops">
|
|
||||||
<template #summary>{{ opsReports.length }} rapports — bientôt disponibles</template>
|
|
||||||
<div class="row q-col-gutter-md">
|
|
||||||
<div class="col-12 col-md-6 col-lg-4" v-for="report in opsReports" :key="report.title">
|
|
||||||
<div class="ops-card cursor-pointer" style="min-height:120px;opacity:0.6" @click="report.action">
|
|
||||||
<div class="row items-center q-mb-sm">
|
|
||||||
<q-icon :name="report.icon" size="28px" :color="report.color" class="q-mr-sm" />
|
|
||||||
<div class="text-subtitle1 text-weight-bold">{{ report.title }}</div>
|
|
||||||
<q-badge class="q-ml-sm" color="grey-4" text-color="grey-7" label="Bientôt" />
|
|
||||||
</div>
|
|
||||||
<div class="text-caption text-grey-6">{{ report.description }}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</DisclosureSection>
|
|
||||||
</q-page>
|
</q-page>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -39,7 +22,6 @@
|
||||||
import { onMounted, onUnmounted } from 'vue'
|
import { onMounted, onUnmounted } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import PageHeader from 'src/components/shared/PageHeader.vue'
|
import PageHeader from 'src/components/shared/PageHeader.vue'
|
||||||
import DisclosureSection from 'src/components/shared/DisclosureSection.vue'
|
|
||||||
import { useCommandPalette } from 'src/composables/useCommandPalette'
|
import { useCommandPalette } from 'src/composables/useCommandPalette'
|
||||||
|
|
||||||
const router = useRouter()
|
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é,
|
// 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.
|
// mais rien n'est « caché » : taper le nom d'un rapport l'ouvre directement.
|
||||||
const { registerActions, unregisterActions } = useCommandPalette()
|
const { registerActions, unregisterActions } = useCommandPalette()
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,8 @@
|
||||||
</div>
|
</div>
|
||||||
<q-space />
|
<q-space />
|
||||||
<q-btn flat dense icon="refresh" :loading="loading" no-caps label="Rafraîchir" @click="load" />
|
<q-btn flat dense icon="refresh" :loading="loading" no-caps label="Rafraîchir" @click="load" />
|
||||||
|
<!-- Numérisation manuelle (photo/upload → OCR → brouillon PI) : même finalité que la capture courriel. -->
|
||||||
|
<q-btn flat dense icon="document_scanner" no-caps label="Numériser" class="q-ml-sm" @click="$router.push('/ocr')" />
|
||||||
<q-btn unelevated dense color="primary" icon="cloud_download" :loading="polling" no-caps label="Relever maintenant" class="q-ml-sm" @click="poll" />
|
<q-btn unelevated dense color="primary" icon="cloud_download" :loading="polling" no-caps label="Relever maintenant" class="q-ml-sm" @click="poll" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
171
services/targo-hub/lib/olt-ops.js
Normal file
171
services/targo-hub/lib/olt-ops.js
Normal file
|
|
@ -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 }
|
||||||
Loading…
Reference in New Issue
Block a user