feat(ops): native Payment Method/Arrangement + VoIP Line sections + G3 Raisecom SNMP writes

Native detail sections (fin de la grille+desk pour les derniers doctypes cliquables de la fiche client) :
- PaymentMethodDetail / PaymentArrangementDetail / VoipLineDetail — LECTURE SEULE (aucune charge/paiement déclenché ; mot de passe SIP jamais affiché). Enregistrés dans SECTION_MAP.

Phase 2 G3 (parité écriture fibre tech-2 Raisecom, ~10 500 ONUs) :
- olt-ops.js : écritures SNMP natives (net-snmp) suspend/rétablir (VLAN 40↔666) / redémarrer / vitesse (line-profile), OIDs EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source) + SAVE 8886.1.2.1.1.0 après chaque set. Encodages olt_id (slot*1e7+port*1e5+ontid) et onu_id ((slot+32)*8388608+port*65536+ontid) — round-trip vérifié. Repli coords ONU depuis le poller SNMP live (olt-snmp.getOnuBySerial) car les coords Raisecom ne sont pas dans Service Equipment. Gaté : env OLT_RW_COMMUNITY requis (absent → run refuse proprement, 0 set), confirm+idempotencyKey obligatoires. Vérifié live : plan tech-2 refuse sans coords/RW ; run refuse ; 0 écriture réseau tirée.
- OnuActionsPanel : ajout « Redémarrer ».

Déployé prod (hub olt-ops + SPA). DetailModal SECTION_MAP (5 entrées ajoutées) = déployé mais co-édité → non commité.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
louispaulb 2026-07-18 22:39:59 -04:00
parent 3d4700decf
commit 23dde4ecf0
5 changed files with 279 additions and 18 deletions

View File

@ -0,0 +1,69 @@
<!--
PaymentArrangementDetail vue NATIVE d'une entente de paiement (échéancier). LECTURE SEULE : montant,
dates, statut, versements. Aucune action de charge remplace la grille générique + bouton desk.
-->
<template>
<div>
<div class="pa-head">
<q-icon name="handshake" size="20px" color="indigo-6" class="q-mr-xs" />
<span class="pa-title">Entente {{ doc.name }}</span>
<span v-if="doc.status" class="ops-badge q-ml-sm">{{ doc.status }}</span>
</div>
<div class="pa-totals">
<div class="pa-card">
<div class="pa-cap">Montant total</div>
<div class="pa-val">{{ formatMoney(doc.total_amount) }}</div>
</div>
<div v-if="doc.installment_count" class="pa-card">
<div class="pa-cap">Versements</div>
<div class="pa-val">{{ doc.installment_count }}</div>
</div>
</div>
<div class="pa-grid">
<div v-if="doc.customer" class="pa-row"><span class="pa-k">Client</span>
<router-link :to="'/clients/' + encodeURIComponent(doc.customer)" class="erp-link">{{ doc.customer_name || doc.customer }}</router-link>
</div>
<div v-if="doc.date_agreed" class="pa-row"><span class="pa-k">Convenu</span>{{ formatDate(doc.date_agreed) }}</div>
<div v-if="doc.date_due" class="pa-row"><span class="pa-k">Échéance</span>{{ formatDate(doc.date_due) }}</div>
<div v-if="doc.note" class="pa-row"><span class="pa-k">Note</span><span>{{ doc.note }}</span></div>
</div>
<!-- Versements (child table Payment Arrangement Installment, si présents) -->
<div v-if="installments.length" class="q-mt-md">
<div class="info-block-title">Échéancier ({{ installments.length }})</div>
<div v-for="(it, i) in installments" :key="i" class="pa-inst">
<span class="pa-inst-date">{{ formatDate(it.due_date || it.date) }}</span>
<span class="pa-inst-amt">{{ formatMoney(it.amount) }}</span>
<span class="pa-inst-st" :class="'pa-st-' + String(it.status || '').toLowerCase()">{{ it.status || '—' }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { formatMoney, formatDate } from 'src/composables/useFormatters'
const props = defineProps({ doc: { type: Object, required: true }, docName: { type: String, default: '' } })
const installments = computed(() => props.doc.installments || props.doc.schedule || [])
</script>
<style scoped>
.pa-head { display: flex; align-items: center; margin-bottom: 10px; }
.pa-title { font-size: 15px; font-weight: 700; }
.pa-totals { display: flex; gap: 10px; margin-bottom: 12px; }
.pa-card { background: var(--ops-soft, #eef2f7); border-radius: 8px; padding: 8px 14px; }
.pa-cap { font-size: 10px; text-transform: uppercase; letter-spacing: .05em; color: #90a4ae; }
.pa-val { font-size: 17px; font-weight: 700; font-variant-numeric: tabular-nums; }
.pa-grid { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.pa-row { display: flex; gap: 8px; }
.pa-k { min-width: 64px; color: #90a4ae; text-transform: uppercase; font-size: 10px; letter-spacing: .05em; padding-top: 2px; }
.pa-inst { display: flex; align-items: center; gap: 10px; font-size: 12.5px; border-top: 1px solid var(--ops-border, #e2e8f0); padding: 4px 0; }
.pa-inst:first-of-type { border-top: none; }
.pa-inst-date { min-width: 90px; }
.pa-inst-amt { font-variant-numeric: tabular-nums; font-weight: 600; }
.pa-inst-st { margin-left: auto; font-size: 11px; font-weight: 700; }
.pa-st-paid, .pa-st-paye { color: #2f855a; }
.pa-st-overdue, .pa-st-retard { color: #b83232; }
</style>

View File

@ -0,0 +1,56 @@
<!--
PaymentMethodDetail vue NATIVE d'un moyen de paiement (Stripe / Paysafe / prélèvement bancaire).
LECTURE SEULE : affiche le fournisseur + identifiants + statut PPA. Ne DÉCLENCHE aucun paiement (aucun bouton
de charge) remplace juste la grille générique + bouton desk ERPNext.
-->
<template>
<div>
<div class="pm-head">
<q-icon :name="providerIcon" size="20px" :color="providerColor" class="q-mr-xs" />
<span class="pm-provider">{{ doc.provider || 'Moyen de paiement' }}</span>
<q-badge v-if="isPpa" color="green-7" class="q-ml-sm">PPA auto</q-badge>
<q-badge v-else color="grey-5" class="q-ml-sm">Manuel</q-badge>
</div>
<div class="pm-grid">
<div v-if="doc.customer" class="pm-row"><span class="pm-k">Client</span>
<router-link :to="'/clients/' + encodeURIComponent(doc.customer)" class="erp-link">{{ doc.customer_name || doc.customer }}</router-link>
</div>
<div v-if="detail" class="pm-row"><span class="pm-k">Référence</span><code>{{ detail }}</code></div>
<div v-if="doc.card_last4" class="pm-row"><span class="pm-k">Carte</span>•••• {{ doc.card_last4 }}<span v-if="doc.card_exp"> · exp. {{ doc.card_exp }}</span></div>
<div v-if="doc.ppa_name" class="pm-row"><span class="pm-k">Titulaire</span>{{ doc.ppa_name }}</div>
<div v-if="doc.ppa_institution" class="pm-row"><span class="pm-k">Banque</span>{{ doc.ppa_institution }}<span v-if="doc.ppa_branch"> · transit {{ doc.ppa_branch }}</span></div>
<div v-if="doc.status" class="pm-row"><span class="pm-k">Statut</span><span class="ops-badge">{{ doc.status }}</span></div>
</div>
<q-banner dense class="bg-blue-grey-1 text-blue-grey-9 pm-note">
<template #avatar><q-icon name="lock" color="blue-grey-6" /></template>
Vue lecture seule. Les charges/prélèvements se pilotent depuis la facturation (jamais dici).
</q-banner>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ doc: { type: Object, required: true }, docName: { type: String, default: '' } })
const isPpa = computed(() => !!(props.doc.is_auto_ppa || props.doc.stripe_ppa_enabled))
const providerIcon = computed(() => ({ Stripe: 'credit_card', Paysafe: 'credit_card', 'Bank Draft': 'account_balance' })[props.doc.provider] || 'payments')
const providerColor = computed(() => ({ Stripe: 'deep-purple-6', Paysafe: 'blue-7', 'Bank Draft': 'teal-7' })[props.doc.provider] || 'grey-7')
const detail = computed(() => {
const d = props.doc
if (d.provider === 'Stripe') return d.stripe_customer_id || ''
if (d.provider === 'Paysafe') return d.paysafe_profile_id || ''
if (d.provider === 'Bank Draft') return d.ppa_institution ? `${d.ppa_institution}-${d.ppa_branch || ''}` : (d.ppa_name || '')
return ''
})
</script>
<style scoped>
.pm-head { display: flex; align-items: center; margin-bottom: 10px; }
.pm-provider { font-size: 15px; font-weight: 700; }
.pm-grid { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.pm-row { display: flex; gap: 8px; }
.pm-k { min-width: 72px; color: #90a4ae; text-transform: uppercase; font-size: 10px; letter-spacing: .05em; padding-top: 2px; }
.pm-note { border-radius: 8px; margin-top: 14px; font-size: 12px; }
</style>

View File

@ -0,0 +1,56 @@
<!--
VoipLineDetail vue NATIVE d'une ligne VoIP (DID, statut, SIP, E911). LECTURE SEULE (le mot de passe SIP
n'est jamais affiché). Remplace la grille générique + bouton desk ERPNext.
-->
<template>
<div>
<div class="vl-head">
<q-icon name="call" size="20px" color="teal-7" class="q-mr-xs" />
<span class="vl-did">{{ doc.did || 'Ligne VoIP' }}</span>
<span v-if="doc.status" class="ops-badge q-ml-sm">{{ doc.status }}</span>
<q-badge :color="doc.e911_synced ? 'green-7' : 'orange-8'" class="q-ml-sm">
{{ doc.e911_synced ? '911 synchronisé' : '911 non synchronisé' }}
</q-badge>
</div>
<div class="vl-grid">
<div v-if="doc.customer" class="vl-row"><span class="vl-k">Client</span>
<router-link :to="'/clients/' + encodeURIComponent(doc.customer)" class="erp-link">{{ doc.customer_name || doc.customer }}</router-link>
</div>
<div v-if="doc.service_location" class="vl-row"><span class="vl-k">Adresse</span>{{ doc.service_location }}</div>
<div v-if="doc.sip_user" class="vl-row"><span class="vl-k">SIP</span><code>{{ doc.sip_user }}</code></div>
<div v-if="doc.sip_domain" class="vl-row"><span class="vl-k">Domaine</span><code>{{ doc.sip_domain }}</code></div>
<div v-if="doc.provider" class="vl-row"><span class="vl-k">Fournisseur</span>{{ doc.provider }}</div>
</div>
<!-- E911 (adresse d'urgence) -->
<div v-if="e911Addr" class="q-mt-md">
<div class="info-block-title"><q-icon name="emergency" size="15px" class="q-mr-xs" />Adresse 911</div>
<div class="vl-e911" :class="{ 'vl-e911-warn': !doc.e911_synced }">
{{ e911Addr }}
<span v-if="!doc.e911_synced" class="vl-e911-flag"> non synchronisée avec le fournisseur 911</span>
</div>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ doc: { type: Object, required: true }, docName: { type: String, default: '' } })
const e911Addr = computed(() => {
const d = props.doc
const parts = [d.e911_civic_number, d.e911_street_name, d.e911_city, d.e911_postal_code].filter(Boolean)
return parts.length ? parts.join(' ') : ''
})
</script>
<style scoped>
.vl-head { display: flex; align-items: center; flex-wrap: wrap; gap: 4px; margin-bottom: 10px; }
.vl-did { font-size: 15px; font-weight: 700; font-variant-numeric: tabular-nums; }
.vl-grid { display: flex; flex-direction: column; gap: 4px; font-size: 13px; }
.vl-row { display: flex; gap: 8px; }
.vl-k { min-width: 72px; color: #90a4ae; text-transform: uppercase; font-size: 10px; letter-spacing: .05em; padding-top: 2px; }
.vl-e911 { font-size: 13px; padding: 8px 10px; background: var(--ops-soft, #eef2f7); border-radius: 8px; }
.vl-e911-warn { background: #fbf0dc; color: #8a5a00; }
.vl-e911-flag { display: block; font-size: 11px; margin-top: 2px; }
</style>

View File

@ -74,6 +74,7 @@ const emit = defineEmits(['changed'])
const menu = [
{ action: 'suspend', label: 'Suspendre', icon: 'pause_circle', color: 'orange-8' },
{ action: 'unsuspend', label: 'Rétablir', icon: 'play_circle', color: 'green-7' },
{ action: 'reboot', label: 'Redémarrer', icon: 'restart_alt', color: 'blue-grey-7' },
{ action: 'speed', label: 'Forfait / vitesse', icon: 'speed', color: 'blue-7' },
{ action: 'replace', label: 'Remplacer', icon: 'swap_horiz', color: 'deep-purple-6' },
{ action: 'remove', label: 'Retirer', icon: 'link_off', color: 'red-7' },

View File

@ -20,15 +20,61 @@ const { log } = require('./helpers')
const N8N_DOSTUFF = 'https://n8napi.targo.ca/webhook/dostuff'
const STORE = '/app/data/olt_ops.json'
const IDEMP_WINDOW_MS = 10 * 60 * 1000 // rejouer la même clé dans 10 min = même résultat (pas de 2e tir)
// Communauté SNMP EN ÉCRITURE (Raisecom tech-2). Secret → env seulement, JAMAIS bundlé. Absente ⇒ run() tech-2 refuse.
const OLT_RW_COMMUNITY = process.env.OLT_RW_COMMUNITY || ''
const OLT_RO_COMMUNITY = process.env.OLT_RO_COMMUNITY || 'targosnmp'
// action → { verb HTTP n8n, champs requis, description FR }
// OIDs Raisecom (MIB 8886) — EXTRAITS DU DRIVER F raisecom_rcmg.php (vérifiés à la source, pas reconstruits) :
// reboot 1.3.6.1.4.1.8886.18.3.6.1.1.1.23.{olt_id}.4 = i 1 (olt_id = slot*1e7+port*1e5+ontid)
// vlan 1.3.6.1.4.1.8886.18.3.6.6.1.1.7.{olt_id}.4 = i 40|666 (40=actif, 666=suspendu)
// profile 1.3.6.1.4.1.8886.18.3.1.3.1.1.6.{onu_id} = i profil (onu_id = (slot+32)*8388608+port*65536+ontid)
// save 1.3.6.1.4.1.8886.1.2.1.1.0 = i 2 (APRÈS chaque set)
const RC = {
REBOOT: (oltId) => `1.3.6.1.4.1.8886.18.3.6.1.1.1.23.${oltId}.4`,
VLAN: (oltId) => `1.3.6.1.4.1.8886.18.3.6.6.1.1.7.${oltId}.4`,
PROFILE: (onuId) => `1.3.6.1.4.1.8886.18.3.1.3.1.1.6.${onuId}`,
SAVE: '1.3.6.1.4.1.8886.1.2.1.1.0',
VLAN_ACTIVE: 40, VLAN_SUSPEND: 666,
}
const rcOltId = (s, p, o) => Number(s) * 10000000 + Number(p) * 100000 + Number(o)
const rcOnuId = (s, p, o) => (Number(s) + 32) * 8388608 + Number(p) * 65536 + Number(o)
// action → verbe n8n (tech-3), champs requis, techs supportées. tech-3 = n8n ; tech-2 = SNMP natif (G3).
const ACTIONS = {
activate: { verb: 'POST', label: 'Activer / provisionner 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'] },
activate: { verb: 'POST', label: 'Activer / provisionner lONU', needs: ['sn', 'olt', 'profileid'], techs: [3] },
replace: { verb: 'PUT', label: 'Remplacer lONU (échange de série)', needs: ['old_sn', 'new_sn', 'olt'], techs: [3] },
speed: { verb: 'PATCH', label: 'Changer le forfait / la vitesse', needs: ['sn', 'olt', 'profileid'], extra: { trigger: 'speedchange' }, techs: [3, 2] },
suspend: { verb: 'PATCH', label: 'Suspendre linternet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'suspend' }, techs: [3, 2] },
unsuspend: { verb: 'PATCH', label: 'Rétablir linternet (VLAN)', needs: ['sn', 'olt'], extra: { profileid: '', trigger: 'unsuspend' }, techs: [3, 2] },
remove: { verb: 'DELETE', label: 'Retirer / désactiver lONU', needs: ['sn', 'olt'], techs: [3] },
reboot: { verb: null, label: 'Redémarrer lONU', needs: ['sn', 'olt'], techs: [2] }, // tech-3 : utiliser le reboot TR-069
}
// SNMP SET Raisecom (tech-2) : applique l'action + SAVE, via net-snmp. Renvoie {ok,error}. LECTURE : voir olt-snmp.
function snmpRaisecom (action, { host, slot, port, ontid, profileid } = {}) {
return new Promise((resolve) => {
if (!OLT_RW_COMMUNITY) return resolve({ ok: false, error: 'OLT_RW_COMMUNITY non configurée (env) — écriture SNMP tech-2 désactivée' })
if (host == null || slot == null || port == null || ontid == null) return resolve({ ok: false, error: 'slot/port/ontid/olt requis' })
let snmp; try { snmp = require('net-snmp') } catch (e) { return resolve({ ok: false, error: 'net-snmp indisponible' }) }
const oltId = rcOltId(slot, port, ontid); const onuId = rcOnuId(slot, port, ontid)
let vb
if (action === 'suspend') vb = { oid: RC.VLAN(oltId), type: snmp.ObjectType.Integer, value: RC.VLAN_SUSPEND }
else if (action === 'unsuspend') vb = { oid: RC.VLAN(oltId), type: snmp.ObjectType.Integer, value: RC.VLAN_ACTIVE }
else if (action === 'reboot') vb = { oid: RC.REBOOT(oltId), type: snmp.ObjectType.Integer, value: 1 }
else if (action === 'speed') { const pr = parseInt(profileid, 10); if (!pr) return resolve({ ok: false, error: 'profileid (n° profil de ligne) requis pour le changement de vitesse' }); vb = { oid: RC.PROFILE(onuId), type: snmp.ObjectType.Integer, value: pr } }
else return resolve({ ok: false, error: 'action SNMP tech-2 non supportée: ' + action })
const session = snmp.createSession(String(host), OLT_RW_COMMUNITY, { timeout: 6000, retries: 1, version: snmp.Version2c })
session.set([vb], (err) => {
if (err) { try { session.close() } catch (e) {} return resolve({ ok: false, error: 'SNMP set: ' + err.message }) }
// SAVE obligatoire après un set (sinon revert silencieux) — sauf reboot (F ne sauve pas après un reboot).
if (action === 'reboot') { try { session.close() } catch (e) {} return resolve({ ok: true, oid: vb.oid }) }
session.set([{ oid: RC.SAVE, type: snmp.ObjectType.Integer, value: 2 }], (err2) => {
try { session.close() } catch (e) {}
if (err2) return resolve({ ok: false, error: 'SNMP save: ' + err2.message, warn: 'set appliqué mais save échoué — peut se réverter' })
resolve({ ok: true, oid: vb.oid, saved: true })
})
})
})
}
function readStore () { try { return JSON.parse(fs.readFileSync(STORE, 'utf8')) } catch (e) { return {} } }
@ -53,7 +99,24 @@ async function resolveEquip (serial) {
fields: ['name', 'serial_number', 'mac_address', 'olt_ip', 'olt_slot', 'olt_port', 'olt_ontid', 'customer', 'service_location', 'status', 'equipment_type'],
limit: 1,
}).catch(() => [])
return (rows && rows[0]) || null
const equip = (rows && rows[0]) || null
// Repli coordonnées ONU depuis le poller SNMP live (olt-snmp) : les coords Raisecom (tech-2) ne sont PAS mirorées
// dans Service Equipment (elles vivent dans la table `fibre` de F) → sans ça, l'écriture SNMP tech-2 est impossible.
if (!equip || equip.olt_ip == null || equip.olt_slot == null || equip.olt_ontid == null) {
try {
const onu = require('./olt-snmp').getOnuBySerial(String(serial))
if (onu) {
const merged = equip || { serial_number: String(serial) }
if (merged.olt_ip == null) merged.olt_ip = onu.oltHost || null
if (merged.olt_slot == null) merged.olt_slot = (onu.slot != null ? onu.slot : null)
if (merged.olt_port == null) merged.olt_port = (onu.port != null ? onu.port : null)
if (merged.olt_ontid == null) merged.olt_ontid = (onu.ontId != null ? onu.ontId : (onu.ontid != null ? onu.ontid : null))
merged._coords_from_snmp = true
return merged
}
} catch (e) { /* poller indispo → on garde equip tel quel */ }
}
return equip
}
// Appel n8n dostuff avec un VERBE arbitraire + body JSON (le module https, comme dostuffStatus).
@ -98,13 +161,24 @@ async function plan ({ serial, action, ...opts } = {}) {
const equip = await resolveEquip(opts.old_sn || serial)
const tech = techOf(opts.old_sn || serial, equip)
const payload = buildPayload(action, equip, { ...opts, sn: serial, old_sn: opts.old_sn || serial })
const method = tech === 2 ? 'snmp' : 'n8n' // tech-2 Raisecom = SNMP natif ; tech-3 TP-Link = n8n
const supportsTech = tech != null && Array.isArray(spec.techs) && spec.techs.includes(tech)
const warnings = []
if (!equip) warnings.push('Aucun Service Equipment pour ce numéro de série — 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.')
else if (!supportsTech) warnings.push('« ' + spec.label + ' » nest pas supporté pour tech-' + tech + (tech === 3 ? ' via ce module (reboot = utiliser TR-069).' : ' (activate/replace/remove Raisecom = script CLI, non branché).'))
if (!payload.olt) warnings.push('IP OLT manquante — requise.')
const missing = spec.needs.filter(k => !payload[k] && payload[k] !== 0)
// tech-2 : besoin des coordonnées ONU (slot/port/ontid) résolues + communauté RW configurée
let snmpReady = true
if (method === 'snmp') {
const haveCoords = equip && equip.olt_slot != null && equip.olt_port != null && equip.olt_ontid != null
if (!haveCoords) { warnings.push('Coordonnées ONU (slot/port/ontid) introuvables sur Service Equipment — requises pour lécriture SNMP.'); snmpReady = false }
if (!OLT_RW_COMMUNITY) { warnings.push('Communauté SNMP en écriture non configurée (env OLT_RW_COMMUNITY) → exécution SNMP désactivée.'); snmpReady = false }
if (action === 'speed' && !(opts.profileid || (equip && equip.fibre_line_profile))) { warnings.push('Profil de ligne (profileid) requis pour le changement de vitesse.'); snmpReady = false }
}
const missing = method === 'n8n' ? spec.needs.filter(k => !payload[k] && payload[k] !== 0) : []
if (missing.length) warnings.push('Champs manquants : ' + missing.join(', '))
const canRun = supportsTech && !!payload.olt && (method === 'n8n' ? (tech === 3 && !missing.length) : snmpReady)
const effects = []
if (action === 'activate') effects.push('Provisionne 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.')
@ -113,12 +187,12 @@ async function plan ({ serial, action, ...opts } = {}) {
if (action === 'unsuspend') effects.push('Rétablit le VLAN internet.')
if (action === 'remove') effects.push('Retire 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 },
ok: canRun,
action, label: spec.label, verb: spec.verb, tech, method,
target: { serial: serial || payload.sn, olt: payload.olt, slot: equip && equip.olt_slot, port: equip && equip.olt_port, ontid: equip && equip.olt_ontid, profile: payload.profileid || (equip && equip.fibre_line_profile) || '' },
equipment: equip ? equip.name : null, customer: equip && equip.customer, service_location: equip && equip.service_location,
payload, effects, warnings,
can_run: tech === 3 && !missing.length && !!payload.olt, // n8n = tech-3 seulement
payload: method === 'n8n' ? payload : { olt: payload.olt, action, snmp: 'MIB 8886 (voir olt-ops.js)' }, effects, warnings,
can_run: canRun,
}
}
@ -135,9 +209,14 @@ async function run ({ serial, action, confirm, idempotencyKey, actor, ...opts }
const store = readStore()
const prev = store[key]
if (prev && (Date.now() - prev.at) < IDEMP_WINDOW_MS) return { ok: true, idempotent: true, ...prev.result }
// FIRE
const net = await dostuff(spec.verb, pl.payload)
const result = { action, serial: pl.target.serial, olt: pl.payload.olt, verb: spec.verb, net_ok: !!net.ok, net_error: net.error || null, net_status: net.status, mirrored: [] }
// FIRE — tech-3 = n8n dostuff ; tech-2 = SNMP set Raisecom (+ save).
let net
if (pl.method === 'snmp') {
net = await snmpRaisecom(action, { host: pl.target.olt, slot: pl.target.slot, port: pl.target.port, ontid: pl.target.ontid, profileid: opts.profileid || (pl.target.profile) })
} else {
net = await dostuff(spec.verb, pl.payload)
}
const result = { action, serial: pl.target.serial, olt: pl.target.olt, method: pl.method, verb: spec.verb, net_ok: !!net.ok, net_error: net.error || null, net_status: net.status, mirrored: [] }
if (net.ok && pl.equipment) {
// Miroir ERPNext (fill-only, best-effort) — reflète l'effet sur Service Equipment.
try {