feat: ONT diagnostics — grouped mesh topology, signal RSSI, management link
- EquipmentDetail: collapsible node groups (clients grouped by mesh node) - Signal strength as RSSI % (0-255 per 802.11-2020) with 10-tone color scale - Management IP clickable link to device web GUI (/superadmin/) - Fibre status compact top bar (status + Rx/Tx power when available) - targo-hub: WAN IP detection across all VLAN interfaces - targo-hub: full WiFi client count (direct + EasyMesh mesh repeaters) - targo-hub: /devices/:id/hosts endpoint with client-to-node mapping - ClientsPage: start empty, load only on search (no auto-load all) - nginx: dynamic ollama resolver (won't crash if ollama is down) - Cleanup: remove unused BillingKPIs.vue and TagInput.vue - New docs and migration scripts Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
fa37426f34
commit
bfffed2b41
|
|
@ -4,6 +4,8 @@ server {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
index index.html;
|
index index.html;
|
||||||
|
|
||||||
|
resolver 127.0.0.11 valid=30s;
|
||||||
|
|
||||||
# ERPNext API proxy — token injected server-side (never in JS bundle)
|
# ERPNext API proxy — token injected server-side (never in JS bundle)
|
||||||
# To rotate: edit this file + docker restart ops-frontend
|
# To rotate: edit this file + docker restart ops-frontend
|
||||||
location /api/ {
|
location /api/ {
|
||||||
|
|
@ -17,9 +19,10 @@ server {
|
||||||
proxy_set_header X-Forwarded-Proto https;
|
proxy_set_header X-Forwarded-Proto https;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ollama Vision API proxy — for bill/invoice OCR
|
# Ollama Vision API proxy — for bill/invoice OCR (dynamic resolve, won't crash if ollama is down)
|
||||||
location /ollama/ {
|
location /ollama/ {
|
||||||
proxy_pass http://ollama:11434/;
|
set $ollama_upstream http://ollama:11434;
|
||||||
|
proxy_pass $ollama_upstream/;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_read_timeout 300s;
|
proxy_read_timeout 300s;
|
||||||
proxy_send_timeout 300s;
|
proxy_send_timeout 300s;
|
||||||
|
|
|
||||||
|
|
@ -1,44 +0,0 @@
|
||||||
<template>
|
|
||||||
<div class="ops-card" style="height:100%">
|
|
||||||
<div class="section-title">
|
|
||||||
<q-icon name="account_balance" size="18px" class="q-mr-xs" /> Facturation
|
|
||||||
</div>
|
|
||||||
<div class="row q-col-gutter-sm">
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-h6 text-weight-bold" style="color:var(--ops-accent)">{{ formatMoney(totalMonthly) }}</div>
|
|
||||||
<div class="kpi-label">Total mensuel</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-h6 text-weight-bold" style="color:var(--ops-danger)">{{ formatMoney(totalOutstanding) }}</div>
|
|
||||||
<div class="kpi-label">Solde dû</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-h6 text-weight-bold">{{ invoiceCount }}</div>
|
|
||||||
<div class="kpi-label">Factures</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6">
|
|
||||||
<div class="text-center">
|
|
||||||
<div class="text-h6 text-weight-bold" style="color:var(--ops-success)">{{ formatMoney(totalPaid) }}</div>
|
|
||||||
<div class="kpi-label">Total payé</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { formatMoney } from 'src/composables/useFormatters'
|
|
||||||
|
|
||||||
defineProps({
|
|
||||||
totalMonthly: { type: Number, default: 0 },
|
|
||||||
totalOutstanding: { type: Number, default: 0 },
|
|
||||||
invoiceCount: { type: Number, default: 0 },
|
|
||||||
totalPaid: { type: Number, default: 0 },
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
@ -1,137 +0,0 @@
|
||||||
<script setup>
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
modelValue: { type: Array, default: () => [] }, // current tag labels
|
|
||||||
allTags: { type: Array, default: () => [] }, // { label, color, category }
|
|
||||||
getColor: { type: Function, default: () => '#6b7280' },
|
|
||||||
placeholder:{ type: String, default: 'Ajouter un tag…' },
|
|
||||||
canCreate: { type: Boolean, default: true },
|
|
||||||
})
|
|
||||||
const emit = defineEmits(['update:modelValue', 'create'])
|
|
||||||
|
|
||||||
const query = ref('')
|
|
||||||
const focused = ref(false)
|
|
||||||
const inputEl = ref(null)
|
|
||||||
|
|
||||||
const filtered = computed(() => {
|
|
||||||
const q = query.value.trim().toLowerCase()
|
|
||||||
if (!q) return props.allTags.filter(t => !props.modelValue.includes(t.label)).slice(0, 12)
|
|
||||||
return props.allTags
|
|
||||||
.filter(t => !props.modelValue.includes(t.label) && t.label.toLowerCase().includes(q))
|
|
||||||
.slice(0, 12)
|
|
||||||
})
|
|
||||||
|
|
||||||
const showCreate = computed(() => {
|
|
||||||
if (!props.canCreate) return false
|
|
||||||
const q = query.value.trim()
|
|
||||||
if (!q || q.length < 2) return false
|
|
||||||
return !props.allTags.some(t => t.label.toLowerCase() === q.toLowerCase())
|
|
||||||
})
|
|
||||||
|
|
||||||
function addTag (label) {
|
|
||||||
if (!label || props.modelValue.includes(label)) return
|
|
||||||
emit('update:modelValue', [...props.modelValue, label])
|
|
||||||
query.value = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeTag (label) {
|
|
||||||
emit('update:modelValue', props.modelValue.filter(t => t !== label))
|
|
||||||
}
|
|
||||||
|
|
||||||
function createAndAdd () {
|
|
||||||
const label = query.value.trim()
|
|
||||||
if (!label) return
|
|
||||||
emit('create', label)
|
|
||||||
addTag(label)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onBlur () {
|
|
||||||
setTimeout(() => { focused.value = false }, 180)
|
|
||||||
}
|
|
||||||
|
|
||||||
function onKeydown (e) {
|
|
||||||
if (e.key === 'Enter' && query.value.trim()) {
|
|
||||||
e.preventDefault()
|
|
||||||
if (filtered.value.length) addTag(filtered.value[0].label)
|
|
||||||
else if (showCreate.value) createAndAdd()
|
|
||||||
}
|
|
||||||
if (e.key === 'Backspace' && !query.value && props.modelValue.length) {
|
|
||||||
removeTag(props.modelValue[props.modelValue.length - 1])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div class="ti-wrap" :class="{ 'ti-focused': focused }">
|
|
||||||
<!-- Existing tags as chips -->
|
|
||||||
<span v-for="t in modelValue" :key="t" class="ti-chip" :style="'background:'+getColor(t)">
|
|
||||||
{{ t }}
|
|
||||||
<button class="ti-chip-rm" @click.stop="removeTag(t)">×</button>
|
|
||||||
</span>
|
|
||||||
<!-- Input -->
|
|
||||||
<input ref="inputEl" class="ti-input" type="text"
|
|
||||||
v-model="query" :placeholder="modelValue.length ? '' : placeholder"
|
|
||||||
@focus="focused=true" @blur="onBlur" @keydown="onKeydown" />
|
|
||||||
<!-- Dropdown -->
|
|
||||||
<div v-if="focused && (filtered.length || showCreate)" class="ti-dropdown">
|
|
||||||
<div v-for="t in filtered" :key="t.label" class="ti-option" @mousedown.prevent="addTag(t.label)">
|
|
||||||
<span class="ti-opt-dot" :style="'background:'+getColor(t.label)"></span>
|
|
||||||
<span class="ti-opt-label">{{ t.label }}</span>
|
|
||||||
<span class="ti-opt-cat">{{ t.category }}</span>
|
|
||||||
</div>
|
|
||||||
<div v-if="showCreate" class="ti-option ti-option-create" @mousedown.prevent="createAndAdd">
|
|
||||||
<span class="ti-create-plus">+</span>
|
|
||||||
<span>Créer « <strong>{{ query.trim() }}</strong> »</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.ti-wrap {
|
|
||||||
display:flex; flex-wrap:wrap; gap:3px; align-items:center;
|
|
||||||
background:#181c2e; border:1px solid rgba(255,255,255,0.06); border-radius:6px;
|
|
||||||
padding:3px 6px; min-height:28px; position:relative; cursor:text;
|
|
||||||
transition: border-color 0.12s;
|
|
||||||
}
|
|
||||||
.ti-wrap.ti-focused { border-color:rgba(99,102,241,0.4); }
|
|
||||||
.ti-chip {
|
|
||||||
display:inline-flex; align-items:center; gap:2px;
|
|
||||||
font-size:0.58rem; font-weight:600; color:#fff;
|
|
||||||
padding:1px 6px; border-radius:10px; white-space:nowrap;
|
|
||||||
}
|
|
||||||
.ti-chip-rm {
|
|
||||||
background:none; border:none; color:rgba(255,255,255,0.6); cursor:pointer;
|
|
||||||
font-size:0.7rem; padding:0 1px; margin-left:1px; line-height:1;
|
|
||||||
}
|
|
||||||
.ti-chip-rm:hover { color:#fff; }
|
|
||||||
.ti-input {
|
|
||||||
flex:1; min-width:60px; background:none; border:none; outline:none;
|
|
||||||
color:#e2e4ef; font-size:0.72rem; padding:2px 0;
|
|
||||||
}
|
|
||||||
.ti-input::placeholder { color:#7b80a0; }
|
|
||||||
.ti-dropdown {
|
|
||||||
position:absolute; top:100%; left:0; right:0; z-index:50;
|
|
||||||
background:#181c2e; border:1px solid rgba(99,102,241,0.3); border-radius:6px;
|
|
||||||
max-height:180px; overflow-y:auto; box-shadow:0 8px 24px rgba(0,0,0,0.45);
|
|
||||||
margin-top:2px;
|
|
||||||
}
|
|
||||||
.ti-dropdown::-webkit-scrollbar { width:3px; }
|
|
||||||
.ti-dropdown::-webkit-scrollbar-thumb { background:rgba(255,255,255,0.1); }
|
|
||||||
.ti-option {
|
|
||||||
display:flex; align-items:center; gap:6px;
|
|
||||||
padding:5px 10px; cursor:pointer; font-size:0.72rem; color:#e2e4ef;
|
|
||||||
transition:background 0.1s;
|
|
||||||
}
|
|
||||||
.ti-option:hover { background:rgba(99,102,241,0.12); }
|
|
||||||
.ti-opt-dot { width:8px; height:8px; border-radius:50%; flex-shrink:0; }
|
|
||||||
.ti-opt-label { flex:1; }
|
|
||||||
.ti-opt-cat { font-size:0.55rem; color:#7b80a0; }
|
|
||||||
.ti-option-create { color:#6366f1; font-weight:600; border-top:1px solid rgba(255,255,255,0.06); }
|
|
||||||
.ti-create-plus {
|
|
||||||
width:18px; height:18px; border-radius:50%; background:rgba(99,102,241,0.2);
|
|
||||||
display:flex; align-items:center; justify-content:center;
|
|
||||||
font-size:0.75rem; font-weight:800; color:#6366f1; flex-shrink:0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
@ -41,6 +41,179 @@
|
||||||
placeholder="—" @saved="v => doc.ownership = v.value" />
|
placeholder="—" @saved="v => doc.ownership = v.value" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Live Diagnostics from GenieACS ── -->
|
||||||
|
<div v-if="device" class="q-mt-md">
|
||||||
|
<div class="info-block-title">
|
||||||
|
Diagnostic en direct
|
||||||
|
<q-badge :color="online ? 'green' : 'red'" class="q-ml-sm">{{ online ? 'En ligne' : 'Hors ligne' }}</q-badge>
|
||||||
|
<span v-if="device.lastInform" class="text-caption text-grey-5 q-ml-sm">{{ timeAgo(device.lastInform) }}</span>
|
||||||
|
<q-btn flat dense size="sm" icon="sync" class="q-ml-sm" :loading="refreshing" @click="doRefresh" title="Rafraichir" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fibre optique — compact top line -->
|
||||||
|
<div class="diag-section fibre-top">
|
||||||
|
<q-icon name="settings_input_hdmi" size="16px" class="q-mr-xs" />
|
||||||
|
<span class="text-weight-bold q-mr-sm">Fibre</span>
|
||||||
|
<q-badge :color="device.opticalStatus === 'Up' ? 'green' : 'red'" class="q-mr-sm">{{ device.opticalStatus || '—' }}</q-badge>
|
||||||
|
<template v-if="device.rxPower != null && device.rxPower !== 0">
|
||||||
|
<span class="diag-label q-mr-xs">Rx</span>
|
||||||
|
<span class="text-weight-medium" :style="{ color: rxColor }">{{ device.rxPower }} dBm</span>
|
||||||
|
</template>
|
||||||
|
<template v-if="device.txPower != null && device.txPower !== 0">
|
||||||
|
<span class="diag-label q-ml-sm q-mr-xs">Tx</span>
|
||||||
|
<span class="text-weight-medium">{{ device.txPower }} dBm</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- IP Addresses -->
|
||||||
|
<div class="diag-section" v-if="device.interfaces && device.interfaces.length">
|
||||||
|
<div class="diag-section-title"><q-icon name="language" size="16px" class="q-mr-xs" />Adresses IP</div>
|
||||||
|
<div class="ip-grid">
|
||||||
|
<div v-for="iface in sortedInterfaces" :key="iface.iface + iface.ip" class="ip-item">
|
||||||
|
<span class="ip-role" :class="'ip-role--' + iface.role">{{ roleLabel(iface.role) }}</span>
|
||||||
|
<a v-if="iface.role === 'management'" :href="'https://' + iface.ip + '/superadmin/'" target="_blank" class="ip-addr ip-link">{{ iface.ip }}</a>
|
||||||
|
<code v-else class="ip-addr">{{ iface.ip }}</code>
|
||||||
|
<span class="text-caption text-grey-5" v-if="iface.name">({{ iface.name }})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WiFi Radios -->
|
||||||
|
<div class="diag-section" v-if="device.wifi">
|
||||||
|
<div class="diag-section-title"><q-icon name="wifi" size="16px" class="q-mr-xs" />WiFi</div>
|
||||||
|
<div class="diag-grid">
|
||||||
|
<template v-for="(radio, key) in wifiRadios" :key="key">
|
||||||
|
<div class="diag-item" v-if="radio.status">
|
||||||
|
<span class="diag-label">{{ radio.label }}</span>
|
||||||
|
<span :class="radio.status === 'Up' ? 'text-positive' : 'text-negative'">
|
||||||
|
{{ radio.status }} · Ch {{ radio.channel || '?' }}
|
||||||
|
<template v-if="radio.bandwidth"> · {{ radio.bandwidth }}</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="diag-item" v-if="radio.status">
|
||||||
|
<span class="diag-label">{{ radio.label }} clients</span>
|
||||||
|
<span class="text-weight-medium">{{ radio.clients }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<!-- Total clients summary (direct + mesh) -->
|
||||||
|
<div class="diag-item" v-if="device.wifi.totalClients > 0">
|
||||||
|
<span class="diag-label">Total clients</span>
|
||||||
|
<span class="text-weight-bold">
|
||||||
|
{{ device.wifi.totalClients }}
|
||||||
|
<span v-if="device.wifi.meshClients > 0" class="text-caption text-grey-6">
|
||||||
|
({{ device.wifi.directClients }} direct + {{ device.wifi.meshClients }} mesh)
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EasyMesh summary (only show if mesh exists but no hosts loaded yet) -->
|
||||||
|
<div class="diag-section" v-if="device.mesh && device.mesh.length > 1 && !hosts">
|
||||||
|
<div class="diag-section-title"><q-icon name="hub" size="16px" class="q-mr-xs" />EasyMesh ({{ device.mesh.length }} noeuds)</div>
|
||||||
|
<div class="mesh-nodes">
|
||||||
|
<div v-for="node in device.mesh" :key="node.id" class="mesh-node">
|
||||||
|
<div class="mesh-node-header">
|
||||||
|
<q-badge :color="node.active ? 'green' : 'red'" class="q-mr-xs" style="padding: 2px 5px;" />
|
||||||
|
<span class="text-weight-medium">{{ node.name || 'Node ' + node.id }}</span>
|
||||||
|
<span class="text-caption text-grey-6 q-ml-xs">{{ node.clients }} clients</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-grey-5" v-if="node.ip || node.mac">
|
||||||
|
<span v-if="node.ip">{{ node.ip }}</span>
|
||||||
|
<span v-if="node.mac"> · {{ node.mac }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ethernet Ports -->
|
||||||
|
<div class="diag-section" v-if="device.ethernet">
|
||||||
|
<div class="diag-section-title"><q-icon name="cable" size="16px" class="q-mr-xs" />Ethernet</div>
|
||||||
|
<div class="diag-grid">
|
||||||
|
<div class="diag-item" v-for="(port, key) in ethernetPorts" :key="key">
|
||||||
|
<span class="diag-label">{{ port.label }}</span>
|
||||||
|
<span :class="port.status === 'Up' ? 'text-positive' : 'text-grey-6'">
|
||||||
|
{{ port.status || '—' }}
|
||||||
|
<template v-if="port.speed && port.speed > 0"> · {{ port.speed }} Mbps</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- General info -->
|
||||||
|
<div class="diag-section">
|
||||||
|
<div class="diag-grid">
|
||||||
|
<div class="diag-item" v-if="device.firmware"><span class="diag-label">Firmware ACS</span><code class="text-caption">{{ device.firmware }}</code></div>
|
||||||
|
<div class="diag-item" v-if="device.ssid"><span class="diag-label">SSID</span>{{ device.ssid }}</div>
|
||||||
|
<div class="diag-item" v-if="device.uptime"><span class="diag-label">Uptime</span>{{ formatUptime(device.uptime) }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connected Clients — grouped by mesh node -->
|
||||||
|
<div class="diag-section">
|
||||||
|
<div class="diag-section-title">
|
||||||
|
<q-icon name="devices" size="16px" class="q-mr-xs" />
|
||||||
|
Appareils connectes
|
||||||
|
<span v-if="hosts" class="text-caption text-grey-5 q-ml-xs">({{ hosts.total }})</span>
|
||||||
|
<q-btn flat dense size="sm" icon="refresh" class="q-ml-sm" :loading="hostsLoading" @click="loadHosts(true)" title="Rafraichir la liste" />
|
||||||
|
</div>
|
||||||
|
<div v-if="hostsLoading" class="text-caption text-grey-5 q-py-xs">
|
||||||
|
<q-spinner size="14px" class="q-mr-xs" /> Interrogation de l'equipement...
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hosts && hosts.hosts.length">
|
||||||
|
<!-- Grouped by node — collapsible -->
|
||||||
|
<div v-for="group in groupedHosts" :key="group.nodeId" class="node-group">
|
||||||
|
<div class="node-group-header" @click="toggleNode(group.nodeId)">
|
||||||
|
<q-icon :name="expandedNodes[group.nodeId] ? 'expand_more' : 'chevron_right'" size="18px" class="node-chevron" />
|
||||||
|
<q-badge :color="group.active ? 'green' : 'red'" class="q-mr-xs" style="padding: 2px 5px;" />
|
||||||
|
<q-icon :name="group.nodeId === '_wired' ? 'cable' : 'router'" size="14px" class="q-mr-xs" :color="group.nodeId === '_wired' ? 'grey-6' : 'blue-6'" />
|
||||||
|
<span class="text-weight-medium">{{ group.label }}</span>
|
||||||
|
<q-badge :color="group.active ? 'blue-1' : 'grey-3'" :text-color="group.active ? 'blue-8' : 'grey-6'" class="q-ml-xs" style="font-size:0.7rem;">
|
||||||
|
{{ group.clients.length }}
|
||||||
|
</q-badge>
|
||||||
|
<span class="text-caption text-grey-5 q-ml-auto" v-if="group.ip">{{ group.ip }}</span>
|
||||||
|
<span class="text-caption text-grey-5 q-ml-xs" v-if="group.mac">· {{ group.mac }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-show="expandedNodes[group.nodeId]">
|
||||||
|
<table class="hosts-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Signal</th>
|
||||||
|
<th>Nom</th>
|
||||||
|
<th>IP</th>
|
||||||
|
<th>MAC</th>
|
||||||
|
<th>Connexion</th>
|
||||||
|
<th>Bail</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr v-for="h in group.clients" :key="h.id" :class="{ 'text-grey-5': !h.active }">
|
||||||
|
<td class="text-caption">
|
||||||
|
<span v-if="h.signal != null" :style="{ color: signalColor(h.signal) }">
|
||||||
|
<q-icon name="signal_cellular_alt" size="12px" :style="{ color: signalColor(h.signal) }" />
|
||||||
|
{{ formatSignal(h.signal) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>—</span>
|
||||||
|
</td>
|
||||||
|
<td class="text-weight-medium">{{ h.name || '—' }}</td>
|
||||||
|
<td><code class="text-caption">{{ h.ip || '—' }}</code></td>
|
||||||
|
<td><code class="text-caption">{{ h.mac || '—' }}</code></td>
|
||||||
|
<td class="text-caption">{{ h.band || h.connType || '—' }}</td>
|
||||||
|
<td class="text-caption">{{ h.leaseRemaining != null ? formatLease(h.leaseRemaining) : '—' }}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="hosts" class="text-caption text-grey-5 q-py-xs">Aucun appareil</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="doc.serial_number && !device" class="q-mt-md text-caption text-grey-5">
|
||||||
|
<q-spinner size="14px" class="q-mr-xs" v-if="deviceLoading" /> Chargement diagnostic ACS...
|
||||||
|
</div>
|
||||||
|
|
||||||
<div v-if="doc.olt_name" class="q-mt-md">
|
<div v-if="doc.olt_name" class="q-mt-md">
|
||||||
<div class="info-block-title">Information OLT</div>
|
<div class="info-block-title">Information OLT</div>
|
||||||
<div class="modal-field-grid">
|
<div class="modal-field-grid">
|
||||||
|
|
@ -75,10 +248,260 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
import InlineField from 'src/components/shared/InlineField.vue'
|
import InlineField from 'src/components/shared/InlineField.vue'
|
||||||
|
import { useDeviceStatus } from 'src/composables/useDeviceStatus'
|
||||||
|
|
||||||
defineProps({
|
const props = defineProps({
|
||||||
doc: { type: Object, required: true },
|
doc: { type: Object, required: true },
|
||||||
docName: String,
|
docName: String,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// ── GenieACS live diagnostics ──
|
||||||
|
const { fetchStatus, getDevice, isOnline, signalQuality, refreshDeviceParams, fetchHosts, loading: deviceLoading } = useDeviceStatus()
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const hosts = ref(null)
|
||||||
|
const hostsLoading = ref(false)
|
||||||
|
const expandedNodes = ref({})
|
||||||
|
|
||||||
|
function toggleNode (nodeId) {
|
||||||
|
expandedNodes.value[nodeId] = !expandedNodes.value[nodeId]
|
||||||
|
}
|
||||||
|
|
||||||
|
const device = computed(() => props.doc.serial_number ? getDevice(props.doc.serial_number) : null)
|
||||||
|
const online = computed(() => isOnline(props.doc.serial_number))
|
||||||
|
|
||||||
|
const rxColor = computed(() => {
|
||||||
|
const q = signalQuality(props.doc.serial_number)
|
||||||
|
if (q === 'excellent') return '#4ade80'
|
||||||
|
if (q === 'good') return '#a3e635'
|
||||||
|
if (q === 'fair') return '#fbbf24'
|
||||||
|
return '#f87171'
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortedInterfaces = computed(() => {
|
||||||
|
if (!device.value?.interfaces) return []
|
||||||
|
const order = { internet: 0, management: 1, service: 2, lan: 3, unknown: 4 }
|
||||||
|
return [...device.value.interfaces].sort((a, b) => (order[a.role] ?? 9) - (order[b.role] ?? 9))
|
||||||
|
})
|
||||||
|
|
||||||
|
function roleLabel (role) {
|
||||||
|
if (role === 'internet') return 'Internet'
|
||||||
|
if (role === 'management') return 'Gestion'
|
||||||
|
if (role === 'service') return 'Service'
|
||||||
|
if (role === 'lan') return 'LAN'
|
||||||
|
return role
|
||||||
|
}
|
||||||
|
|
||||||
|
const wifiRadios = computed(() => {
|
||||||
|
if (!device.value?.wifi) return []
|
||||||
|
const radios = []
|
||||||
|
const w = device.value.wifi
|
||||||
|
if (w.radio1?.status) radios.push({ ...w.radio1, label: '2.4 GHz' })
|
||||||
|
if (w.radio2?.status) radios.push({ ...w.radio2, label: '5 GHz' })
|
||||||
|
if (w.radio3?.status) radios.push({ ...w.radio3, label: '6 GHz' })
|
||||||
|
return radios
|
||||||
|
})
|
||||||
|
|
||||||
|
const ethernetPorts = computed(() => {
|
||||||
|
if (!device.value?.ethernet) return []
|
||||||
|
const ports = []
|
||||||
|
const e = device.value.ethernet
|
||||||
|
if (e.port1) ports.push({ ...e.port1, label: 'Port 1' })
|
||||||
|
if (e.port2) ports.push({ ...e.port2, label: 'Port 2' })
|
||||||
|
return ports
|
||||||
|
})
|
||||||
|
|
||||||
|
// Group hosts by mesh node for the grouped view
|
||||||
|
const groupedHosts = computed(() => {
|
||||||
|
if (!hosts.value?.hosts) return []
|
||||||
|
const meshNodes = device.value?.mesh || []
|
||||||
|
const groups = new Map()
|
||||||
|
|
||||||
|
// Initialize groups from mesh topology (preserves node order)
|
||||||
|
for (const node of meshNodes) {
|
||||||
|
groups.set(node.name || node.id, {
|
||||||
|
nodeId: node.name || node.id,
|
||||||
|
label: node.name || 'Node ' + node.id,
|
||||||
|
active: node.active,
|
||||||
|
ip: node.ip,
|
||||||
|
mac: node.mac,
|
||||||
|
clients: [],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Separate wired / unknown group
|
||||||
|
const wiredGroup = { nodeId: '_wired', label: 'Filaire / Autre', active: true, ip: null, mac: null, clients: [] }
|
||||||
|
|
||||||
|
for (const h of hosts.value.hosts) {
|
||||||
|
if (h.isMeshNode) continue // don't list mesh nodes themselves as clients
|
||||||
|
const nodeName = h.attachedNode
|
||||||
|
if (nodeName && groups.has(nodeName)) {
|
||||||
|
groups.get(nodeName).clients.push(h)
|
||||||
|
} else if (nodeName) {
|
||||||
|
// Node not in mesh topology — create ad-hoc group
|
||||||
|
groups.set(nodeName, { nodeId: nodeName, label: nodeName, active: true, ip: null, mac: null, clients: [h] })
|
||||||
|
} else {
|
||||||
|
wiredGroup.clients.push(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = [...groups.values()]
|
||||||
|
if (wiredGroup.clients.length) result.push(wiredGroup)
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
// Signal: RSSI 0–255 (vendor, 802.11-2020) → 0–100%, or negative dBm
|
||||||
|
// 10 discrete color steps: 0%=red, 40%=orange, 60%=yellow, 100%=green
|
||||||
|
// Source: hsl hue interpolation, red(0°) → green(120°), 10 tones
|
||||||
|
const SIGNAL_STEPS = [
|
||||||
|
{ pct: 0, color: 'hsl(0, 100%, 40%)' }, // red
|
||||||
|
{ pct: 10, color: 'hsl(6, 100%, 42%)' },
|
||||||
|
{ pct: 20, color: 'hsl(12, 100%, 44%)' },
|
||||||
|
{ pct: 30, color: 'hsl(20, 95%, 45%)' },
|
||||||
|
{ pct: 40, color: 'hsl(30, 100%, 45%)' }, // orange
|
||||||
|
{ pct: 50, color: 'hsl(42, 100%, 45%)' },
|
||||||
|
{ pct: 60, color: 'hsl(54, 100%, 40%)' }, // yellow
|
||||||
|
{ pct: 70, color: 'hsl(72, 90%, 38%)' },
|
||||||
|
{ pct: 80, color: 'hsl(90, 80%, 36%)' },
|
||||||
|
{ pct: 90, color: 'hsl(105, 75%, 35%)' },
|
||||||
|
{ pct: 100, color: 'hsl(120, 70%, 35%)' }, // green
|
||||||
|
]
|
||||||
|
|
||||||
|
function signalColor (raw) {
|
||||||
|
if (raw == null) return '#9ca3af'
|
||||||
|
const val = parseFloat(raw)
|
||||||
|
if (isNaN(val)) return '#9ca3af'
|
||||||
|
const pct = val >= 0
|
||||||
|
? Math.round((val / 255) * 100)
|
||||||
|
: Math.round(Math.max(0, Math.min(100, ((val + 90) / 60) * 100)))
|
||||||
|
// Find matching step (round down to nearest 10)
|
||||||
|
const idx = Math.min(10, Math.max(0, Math.floor(pct / 10)))
|
||||||
|
return SIGNAL_STEPS[idx].color
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSignal (raw) {
|
||||||
|
if (raw == null) return null
|
||||||
|
const val = parseFloat(raw)
|
||||||
|
if (isNaN(val)) return null
|
||||||
|
if (val >= 0) return Math.round((val / 255) * 100) + '%'
|
||||||
|
return val + ' dBm'
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo (dateStr) {
|
||||||
|
if (!dateStr) return ''
|
||||||
|
const diff = Date.now() - new Date(dateStr).getTime()
|
||||||
|
const mins = Math.floor(diff / 60000)
|
||||||
|
if (mins < 1) return "a l'instant"
|
||||||
|
if (mins < 60) return `il y a ${mins}m`
|
||||||
|
const hrs = Math.floor(mins / 60)
|
||||||
|
if (hrs < 24) return `il y a ${hrs}h`
|
||||||
|
return `il y a ${Math.floor(hrs / 24)}j`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLease (seconds) {
|
||||||
|
if (seconds == null || seconds <= 0) return 'expire'
|
||||||
|
const h = Math.floor(seconds / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (h > 0) return `${h}h${m > 0 ? m + 'm' : ''}`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadHosts (refresh = true) {
|
||||||
|
if (!props.doc.serial_number) return
|
||||||
|
hostsLoading.value = true
|
||||||
|
try {
|
||||||
|
hosts.value = await fetchHosts(props.doc.serial_number, refresh)
|
||||||
|
// Auto-expand all node groups on first load
|
||||||
|
if (hosts.value?.hosts) {
|
||||||
|
const nodes = new Set()
|
||||||
|
for (const h of hosts.value.hosts) {
|
||||||
|
if (h.attachedNode) nodes.add(h.attachedNode)
|
||||||
|
else nodes.add('_wired')
|
||||||
|
}
|
||||||
|
// Also add mesh node names
|
||||||
|
if (device.value?.mesh) {
|
||||||
|
for (const n of device.value.mesh) nodes.add(n.name || n.id)
|
||||||
|
}
|
||||||
|
const exp = {}
|
||||||
|
for (const id of nodes) exp[id] = true
|
||||||
|
expandedNodes.value = exp
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
hostsLoading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUptime (seconds) {
|
||||||
|
if (!seconds) return '—'
|
||||||
|
const d = Math.floor(seconds / 86400)
|
||||||
|
const h = Math.floor((seconds % 86400) / 3600)
|
||||||
|
const m = Math.floor((seconds % 3600) / 60)
|
||||||
|
if (d > 0) return `${d}j ${h}h`
|
||||||
|
if (h > 0) return `${h}h ${m}m`
|
||||||
|
return `${m}m`
|
||||||
|
}
|
||||||
|
|
||||||
|
async function doRefresh () {
|
||||||
|
if (!props.doc.serial_number) return
|
||||||
|
refreshing.value = true
|
||||||
|
try {
|
||||||
|
await refreshDeviceParams(props.doc.serial_number)
|
||||||
|
// Re-fetch after a short delay to get updated data
|
||||||
|
setTimeout(() => {
|
||||||
|
fetchStatus([{ serial_number: props.doc.serial_number }])
|
||||||
|
refreshing.value = false
|
||||||
|
}, 3000)
|
||||||
|
} catch {
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-fetch device status when serial is available
|
||||||
|
onMounted(() => {
|
||||||
|
if (props.doc.serial_number) {
|
||||||
|
fetchStatus([{ serial_number: props.doc.serial_number }])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Load hosts once device is resolved
|
||||||
|
watch(device, (d) => {
|
||||||
|
if (d && !hosts.value) loadHosts()
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(() => props.doc.serial_number, (sn) => {
|
||||||
|
if (sn) {
|
||||||
|
hosts.value = null
|
||||||
|
fetchStatus([{ serial_number: sn }])
|
||||||
|
}
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.diag-section { margin-top: 8px; }
|
||||||
|
.fibre-top { display: flex; align-items: center; font-size: 0.84rem; padding: 6px 8px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
|
||||||
|
.diag-section-title { font-size: 0.78rem; font-weight: 600; color: #4b5563; margin-bottom: 4px; display: flex; align-items: center; }
|
||||||
|
.diag-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 2px 16px; }
|
||||||
|
.diag-item { display: flex; align-items: baseline; gap: 6px; padding: 4px 0; font-size: 0.84rem; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
.diag-label { font-size: 0.72rem; font-weight: 600; color: #9ca3af; min-width: 72px; flex-shrink: 0; }
|
||||||
|
.ip-grid { display: flex; flex-direction: column; gap: 3px; }
|
||||||
|
.ip-item { display: flex; align-items: center; gap: 8px; padding: 3px 0; font-size: 0.84rem; }
|
||||||
|
.ip-role { font-size: 0.68rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.3px; padding: 1px 6px; border-radius: 3px; min-width: 58px; text-align: center; }
|
||||||
|
.ip-role--internet { background: #dcfce7; color: #166534; }
|
||||||
|
.ip-role--management { background: #dbeafe; color: #1e40af; }
|
||||||
|
.ip-role--service { background: #f3e8ff; color: #6b21a8; }
|
||||||
|
.ip-role--lan { background: #f1f5f9; color: #475569; }
|
||||||
|
.ip-addr { font-size: 0.84rem; font-weight: 600; }
|
||||||
|
.ip-link { color: #1e40af; text-decoration: none; font-family: monospace; }
|
||||||
|
.ip-link:hover { text-decoration: underline; }
|
||||||
|
.mesh-nodes { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.mesh-node { padding: 6px 8px; background: #f8fafc; border-radius: 6px; border: 1px solid #e2e8f0; }
|
||||||
|
.mesh-node-header { display: flex; align-items: center; font-size: 0.84rem; }
|
||||||
|
.node-group { margin-bottom: 8px; border: 1px solid #e2e8f0; border-radius: 8px; overflow: hidden; }
|
||||||
|
.node-group-header { display: flex; align-items: center; padding: 6px 10px; background: #f8fafc; border-bottom: 1px solid #e2e8f0; font-size: 0.82rem; cursor: pointer; user-select: none; }
|
||||||
|
.node-group-header:hover { background: #f1f5f9; }
|
||||||
|
.node-chevron { color: #9ca3af; margin-right: 2px; transition: transform 0.15s; }
|
||||||
|
.hosts-table { width: 100%; border-collapse: collapse; font-size: 0.8rem; }
|
||||||
|
.hosts-table th { text-align: left; font-size: 0.68rem; font-weight: 600; color: #9ca3af; padding: 3px 6px; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
.hosts-table td { padding: 3px 6px; border-bottom: 1px solid #f1f5f9; }
|
||||||
|
.hosts-table code { font-size: 0.72rem; }
|
||||||
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -133,6 +133,15 @@ export function useDeviceStatus () {
|
||||||
return res.json()
|
return res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchHosts (serial, refresh = false) {
|
||||||
|
const d = getDevice(serial)
|
||||||
|
if (!d) return null
|
||||||
|
const url = `${HUB_URL}/devices/${encodeURIComponent(d._id)}/hosts${refresh ? '?refresh' : ''}`
|
||||||
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return res.json()
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deviceMap: readonly(deviceMap),
|
deviceMap: readonly(deviceMap),
|
||||||
loading: readonly(loading),
|
loading: readonly(loading),
|
||||||
|
|
@ -143,5 +152,6 @@ export function useDeviceStatus () {
|
||||||
signalQuality,
|
signalQuality,
|
||||||
rebootDevice,
|
rebootDevice,
|
||||||
refreshDeviceParams,
|
refreshDeviceParams,
|
||||||
|
fetchHosts,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -110,14 +110,23 @@
|
||||||
— {{ formatTimeAgo(getDevice(eq.serial_number).lastInform) }}
|
— {{ formatTimeAgo(getDevice(eq.serial_number).lastInform) }}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<template v-if="getDevice(eq.serial_number).firmware"><div>FW: {{ getDevice(eq.serial_number).firmware }}</div></template>
|
<template v-if="getDevice(eq.serial_number).opticalStatus">
|
||||||
<template v-if="getDevice(eq.serial_number).ip"><div>WAN IP: {{ getDevice(eq.serial_number).ip }}</div></template>
|
<div>Fibre: <span :style="{ color: getDevice(eq.serial_number).opticalStatus === 'Up' ? '#4ade80' : '#f87171' }">{{ getDevice(eq.serial_number).opticalStatus }}</span></div>
|
||||||
<template v-if="getDevice(eq.serial_number).rxPower != null">
|
</template>
|
||||||
|
<template v-if="getDevice(eq.serial_number).rxPower != null && getDevice(eq.serial_number).rxPower !== 0">
|
||||||
<div :style="{ color: signalColor(eq.serial_number) }">
|
<div :style="{ color: signalColor(eq.serial_number) }">
|
||||||
Rx: {{ getDevice(eq.serial_number).rxPower }} dBm
|
Rx: {{ getDevice(eq.serial_number).rxPower }} dBm
|
||||||
<template v-if="getDevice(eq.serial_number).txPower != null"> / Tx: {{ getDevice(eq.serial_number).txPower }} dBm</template>
|
<template v-if="getDevice(eq.serial_number).txPower != null && getDevice(eq.serial_number).txPower !== 0"> / Tx: {{ getDevice(eq.serial_number).txPower }} dBm</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-if="getDevice(eq.serial_number).wifi">
|
||||||
|
<div v-if="getDevice(eq.serial_number).wifi.totalClients != null || getDevice(eq.serial_number).wifi.radio1?.clients != null">
|
||||||
|
WiFi: {{ getDevice(eq.serial_number).wifi.totalClients != null ? getDevice(eq.serial_number).wifi.totalClients : ((getDevice(eq.serial_number).wifi.radio1?.clients || 0) + (getDevice(eq.serial_number).wifi.radio2?.clients || 0) + (getDevice(eq.serial_number).wifi.radio3?.clients || 0)) }} clients
|
||||||
|
<span v-if="getDevice(eq.serial_number).wifi.meshClients > 0">({{ getDevice(eq.serial_number).wifi.meshClients }} via mesh)</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-if="getDevice(eq.serial_number).firmware"><div>FW: {{ getDevice(eq.serial_number).firmware }}</div></template>
|
||||||
|
<template v-if="getDevice(eq.serial_number).ip"><div>WAN IP: {{ getDevice(eq.serial_number).ip }}</div></template>
|
||||||
<template v-if="getDevice(eq.serial_number).ssid"><div>SSID: {{ getDevice(eq.serial_number).ssid }}</div></template>
|
<template v-if="getDevice(eq.serial_number).ssid"><div>SSID: {{ getDevice(eq.serial_number).ssid }}</div></template>
|
||||||
</template>
|
</template>
|
||||||
</q-tooltip>
|
</q-tooltip>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@
|
||||||
<template #prepend><q-icon name="search" /></template>
|
<template #prepend><q-icon name="search" /></template>
|
||||||
<template #append>
|
<template #append>
|
||||||
<q-spinner v-if="loading" size="16px" color="grey-5" />
|
<q-spinner v-if="loading" size="16px" color="grey-5" />
|
||||||
<q-icon v-else-if="search" name="close" class="cursor-pointer" @click="search = ''; doSearch()" />
|
<q-icon v-else-if="search" name="close" class="cursor-pointer" @click="search = ''; clients = []; total = 0" />
|
||||||
</template>
|
</template>
|
||||||
</q-input>
|
</q-input>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -150,6 +150,9 @@ function onRequest (props) {
|
||||||
doSearch()
|
doSearch()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(doSearch)
|
// Don't auto-load all clients — start empty, load only on search or if ?q= is present
|
||||||
|
onMounted(() => {
|
||||||
|
if (search.value.trim()) doSearch()
|
||||||
|
})
|
||||||
watch(() => route.query.q, (q) => { if (q) { search.value = q; doSearch() } })
|
watch(() => route.query.q, (q) => { if (q) { search.value = q; doSearch() } })
|
||||||
</script>
|
</script>
|
||||||
|
|
|
||||||
|
|
@ -228,7 +228,25 @@ One-time charges:
|
||||||
4. **Installation preference**:
|
4. **Installation preference**:
|
||||||
- Choose from 3 available dates (next 2 weeks, exclude weekends)
|
- Choose from 3 available dates (next 2 weeks, exclude weekends)
|
||||||
- OR "Contactez-moi" (we call to schedule)
|
- OR "Contactez-moi" (we call to schedule)
|
||||||
5. **Payment** via Stripe (first month + one-time charges)
|
5. **Payment** (Stripe — card registered but NOT charged)
|
||||||
|
|
||||||
|
### Payment Strategy
|
||||||
|
|
||||||
|
**Website path (self-service)**:
|
||||||
|
- Customer registers their card on Stripe (SetupIntent, not PaymentIntent)
|
||||||
|
- We create a Stripe Customer + attach the payment method
|
||||||
|
- **No charge is taken at checkout** — customer sees: "Aucun frais avant la complétion de l'installation à votre satisfaction"
|
||||||
|
- Payment is collected AFTER installation is confirmed complete by the technician
|
||||||
|
- On installation completion → auto-charge first month + one-time fees via Stripe
|
||||||
|
|
||||||
|
**Agent path (phone order)**:
|
||||||
|
- No payment collected upfront
|
||||||
|
- Agent creates the order and an invoice is generated
|
||||||
|
- Agent can send a **payment link** via email or SMS to the customer:
|
||||||
|
- Stripe Checkout Session or Payment Link → customer pays when ready
|
||||||
|
- SMS via Twilio: "Votre commande est confirmée! Payez ici: {link}"
|
||||||
|
- Email via Mailjet: order summary + payment button
|
||||||
|
- Payment can also be collected on-site by tech or post-installation
|
||||||
|
|
||||||
### What Happens on Submit
|
### What Happens on Submit
|
||||||
|
|
||||||
|
|
@ -243,7 +261,14 @@ POST /api/checkout
|
||||||
{ sku: "TELEPMENS", type: "phone" }
|
{ sku: "TELEPMENS", type: "phone" }
|
||||||
],
|
],
|
||||||
preferred_dates: ["2026-04-07", "2026-04-09", "2026-04-11"],
|
preferred_dates: ["2026-04-07", "2026-04-09", "2026-04-11"],
|
||||||
stripe_payment_intent: "pi_xxx"
|
payment: {
|
||||||
|
method: "stripe_setup", // website: card registered, not charged
|
||||||
|
stripe_customer_id: "cus_xxx", // Stripe Customer created
|
||||||
|
stripe_payment_method: "pm_xxx", // Card saved for later charge
|
||||||
|
// OR for agent path:
|
||||||
|
method: "invoice_later", // agent: no payment upfront
|
||||||
|
send_payment_link: "email|sms|both" // optional: send link to customer
|
||||||
|
}
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,20 @@ Output: customer data ready for creation
|
||||||
|
|
||||||
### Step 4: Payment (Stripe)
|
### Step 4: Payment (Stripe)
|
||||||
```
|
```
|
||||||
Input: card details (Stripe Elements)
|
WEBSITE PATH:
|
||||||
Output: payment_intent confirmed
|
Input: card details (Stripe Elements)
|
||||||
First month pro-rated + one-time charges
|
Output: SetupIntent confirmed — card SAVED, NOT charged
|
||||||
|
Stripe Customer + PaymentMethod created
|
||||||
|
Customer sees: "Aucun frais avant la complétion de l'installation"
|
||||||
|
Charge triggered AFTER tech confirms installation complete
|
||||||
|
|
||||||
|
AGENT PATH:
|
||||||
|
No payment collected upfront
|
||||||
|
Invoice generated → agent sends payment link via email/SMS
|
||||||
|
POST /api/send-payment-link
|
||||||
|
{ customer_id, method: "email|sms|both" }
|
||||||
|
→ Creates Stripe Payment Link or Checkout Session
|
||||||
|
→ Sends via Twilio SMS / Mailjet email
|
||||||
```
|
```
|
||||||
|
|
||||||
### Step 5: Order Creation (atomic — all or nothing)
|
### Step 5: Order Creation (atomic — all or nothing)
|
||||||
|
|
|
||||||
67
docs/DESIGN_GUIDELINES.md
Normal file
67
docs/DESIGN_GUIDELINES.md
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
# Gigafibre FSM — Design Guidelines
|
||||||
|
|
||||||
|
This document outlines the standard design principles and conventions for adding new features and modules to the Gigafibre FSM (Field Service Management) application. Adhering to these guidelines ensures scalability, maintainability, and highly efficient AI-assisted development (by keeping context windows small and token usage low).
|
||||||
|
|
||||||
|
## 1. Modular Architecture (Feature-Sliced Design)
|
||||||
|
|
||||||
|
To avoid a monolithic `src/` folder that overwhelms both developers and AI tools, organize code by **feature** rather than strictly by technical type.
|
||||||
|
|
||||||
|
**Do Not Do This (Technical Grouping):**
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
components/ (contains dispatch, inventory, and customer components all mixed together)
|
||||||
|
store/ (one massive pinia store for everything)
|
||||||
|
api/ (one 2000-line api.js file)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do This (Feature Grouping):**
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
features/
|
||||||
|
dispatch/
|
||||||
|
components/
|
||||||
|
store.ts
|
||||||
|
api.ts
|
||||||
|
types.ts
|
||||||
|
equipment/
|
||||||
|
components/
|
||||||
|
store.ts
|
||||||
|
api.ts
|
||||||
|
types.ts
|
||||||
|
shared/
|
||||||
|
ui/ (generic buttons, dialogs)
|
||||||
|
utils/
|
||||||
|
```
|
||||||
|
*Why?* When you need AI to build a new dispatch feature, you only need to feed it the `features/dispatch/` folder, drastically reducing token usage and hallucination.
|
||||||
|
|
||||||
|
## 2. API & ERPNext Abstraction
|
||||||
|
|
||||||
|
Never make raw API calls (`axios.get`) directly inside a Vue component.
|
||||||
|
* All ERPNext interactions must go through a dedicated API service file (`features/{module}/api.ts`).
|
||||||
|
* **Rule:** Vue components should only dispatch actions (via Pinia) or call cleanly abstracted service functions. They should not care about Frappe endpoints or REST wrappers.
|
||||||
|
|
||||||
|
## 3. UI Component Standardization (Quasar)
|
||||||
|
|
||||||
|
* **Composition API:** Use Vue 3 `<script setup>` syntax universally. Avoid Options API entirely.
|
||||||
|
* **Component Size Limit:** If a `.vue` file exceeds **250 lines**, it must be split. Extract complex tables, modal dialogs, or forms into their own sub-components.
|
||||||
|
* **Dumb vs. Smart Components:**
|
||||||
|
* *Smart Components (Pages):* Handle Pinia state, fetch data from the API, and pass variables down.
|
||||||
|
* *Dumb Components (UI elements):* Only accept `props` and emit `events`. They do not fetch their own data.
|
||||||
|
|
||||||
|
## 4. State Management (Pinia)
|
||||||
|
|
||||||
|
* Use one Pinia store per domain/module (e.g., `useDispatchStore`, `useEquipmentStore`).
|
||||||
|
* **Do not store UI state in Pinia** (like "isTheSidebarOpen"). Pinia is for caching ERPNext data locally (Jobs, Technicians, Inventory). Use local `ref()` for UI toggles.
|
||||||
|
|
||||||
|
## 5. Standardizing Frappe/ERPNext Backend Additions
|
||||||
|
|
||||||
|
When creating a new custom Doctype or Python module in ERPNext for the FSM:
|
||||||
|
1. **Naming Convention:** Prefix system-specific Doctypes with logical boundaries if needed, but rely on standard Frappe naming (e.g., `Dispatch Job`, `Service Location`).
|
||||||
|
2. **Controller Logic:** Keep Python hooks small. If a `before_save` hook exceeds 50 lines, abstract the logic into a separate Python utility file.
|
||||||
|
3. **API Endpoints:** Use `@frappe.whitelist()` cleanly. Validate permissions explicitly inside the whitelisted function before returning data to the Vue app.
|
||||||
|
|
||||||
|
## 6. AI & Token Context Optimization
|
||||||
|
|
||||||
|
To ensure AI (Claude/Gemini) can easily understand and edit this project in the future:
|
||||||
|
* **Avoid "God Objects":** Keep configurations, massive constant arrays, or lookup dictionaries in separate `constants.ts` files so they don't bloat the context window of standard logical files.
|
||||||
|
* **Strict Typing:** Use TypeScript interfaces (`types.ts`) aggressively. If the AI can read your interfaces, it immediately understands your entire data model without needing to look at your database backend.
|
||||||
415
docs/FIELD-APP-WIZARD-UX.md
Normal file
415
docs/FIELD-APP-WIZARD-UX.md
Normal file
|
|
@ -0,0 +1,415 @@
|
||||||
|
# Field Tech App — Wizard UX & Form Customizer
|
||||||
|
|
||||||
|
## Design Principles
|
||||||
|
|
||||||
|
1. **Minimal inputs per screen** — Techs are manual workers, not desk jockeys. Max 2-3 fields per step.
|
||||||
|
2. **Carousel/swipe navigation** — Next/Back with swipe gestures, not scrolling a long form.
|
||||||
|
3. **Big touch targets** — Fat fingers in work gloves. Buttons 48px+ height, inputs 56px+.
|
||||||
|
4. **Auto-advance** — When a field is selected (like equipment_type), auto-advance to next step.
|
||||||
|
5. **Offline-first** — Everything queued if no signal. Sync indicator always visible.
|
||||||
|
6. **Customizable** — Admin can add/remove/reorder steps via an Odoo Studio-like builder.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Wizard Component Architecture
|
||||||
|
|
||||||
|
### Core: `WizardCarousel.vue`
|
||||||
|
|
||||||
|
A reusable carousel-based wizard that renders steps from a JSON definition:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<WizardCarousel
|
||||||
|
:steps="wizardSteps"
|
||||||
|
:context="{ job, customer, location }"
|
||||||
|
@complete="onComplete"
|
||||||
|
@cancel="onCancel"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
Each step is a self-contained screen rendered in a `q-carousel-slide`:
|
||||||
|
- Swipe left/right to navigate
|
||||||
|
- Bottom progress dots
|
||||||
|
- "Suivant" / "Précédent" buttons (large, thumb-friendly)
|
||||||
|
- Step counter: "Étape 2/5"
|
||||||
|
|
||||||
|
### Step Definition Schema
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"id": "equipment_type",
|
||||||
|
"title": "Type d'équipement",
|
||||||
|
"icon": "router",
|
||||||
|
"fields": [
|
||||||
|
{
|
||||||
|
"name": "equipment_type",
|
||||||
|
"type": "select-cards",
|
||||||
|
"label": "Quel équipement?",
|
||||||
|
"options": [
|
||||||
|
{ "value": "ONT", "label": "ONT", "icon": "settings_input_hdmi", "color": "blue" },
|
||||||
|
{ "value": "Routeur", "label": "Routeur WiFi", "icon": "wifi", "color": "green" },
|
||||||
|
{ "value": "Décodeur TV", "label": "Décodeur TV", "icon": "tv", "color": "purple" },
|
||||||
|
{ "value": "Téléphone IP", "label": "Téléphone", "icon": "phone", "color": "orange" }
|
||||||
|
],
|
||||||
|
"auto_advance": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"visible_if": null,
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Field Types
|
||||||
|
|
||||||
|
| Type | Render | Use Case |
|
||||||
|
|------|--------|----------|
|
||||||
|
| `select-cards` | Big tappable cards with icons | Equipment type, status, connection type |
|
||||||
|
| `text` | Single large input | Serial number, name |
|
||||||
|
| `scan` | Camera button → barcode scanner | Serial, MAC address |
|
||||||
|
| `photo` | Camera button → photo capture | Equipment photo, site photo |
|
||||||
|
| `signature` | Touch signature pad | Customer sign-off |
|
||||||
|
| `toggle` | Large yes/no toggle | Signal OK?, WiFi working? |
|
||||||
|
| `notes` | Textarea (expandable) | Completion notes |
|
||||||
|
| `date` | Date picker | Schedule date |
|
||||||
|
| `search` | Autocomplete search | Customer lookup |
|
||||||
|
| `checklist` | List of checkable items | Installation checklist |
|
||||||
|
| `info` | Read-only display card | Summary, confirmation |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Example Wizards
|
||||||
|
|
||||||
|
### 1. Installation Completion Wizard
|
||||||
|
|
||||||
|
When tech taps "Compléter" on a Dispatch Job:
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: "Équipements installés" [checklist]
|
||||||
|
☐ ONT branché et signal OK
|
||||||
|
☐ Routeur WiFi configuré
|
||||||
|
☐ Décodeur TV branché (if TV service)
|
||||||
|
☐ Téléphone IP testé (if phone service)
|
||||||
|
|
||||||
|
Step 2: "Numéros de série" [scan]
|
||||||
|
→ Camera opens, scan ONT barcode
|
||||||
|
→ Auto-detected: RCMG19E0AB57
|
||||||
|
→ "Suivant" button
|
||||||
|
|
||||||
|
Step 3: "Signal ONT" [toggle + text]
|
||||||
|
Signal OK? [OUI / NON]
|
||||||
|
Niveau signal: [-20 dBm] (optional)
|
||||||
|
|
||||||
|
Step 4: "WiFi" [toggle]
|
||||||
|
Client connecté au WiFi? [OUI / NON]
|
||||||
|
SSID affiché: "Gigafibre-Tremblay"
|
||||||
|
|
||||||
|
Step 5: "Photo du boîtier" [photo]
|
||||||
|
📸 Prendre une photo
|
||||||
|
|
||||||
|
Step 6: "Signature client" [signature]
|
||||||
|
Le client confirme que le service fonctionne
|
||||||
|
|
||||||
|
Step 7: "Résumé" [info]
|
||||||
|
✓ ONT: RCMG19E0AB57
|
||||||
|
✓ Signal: -18.5 dBm
|
||||||
|
✓ WiFi: OK
|
||||||
|
✓ Photo: 1 prise
|
||||||
|
✓ Signé par: Jean Tremblay
|
||||||
|
[Confirmer l'installation]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Equipment Scan & Link Wizard
|
||||||
|
|
||||||
|
When tech scans a barcode (from ScanPage or Job context):
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: "Scanner" [scan]
|
||||||
|
📸 Scanner le code-barres
|
||||||
|
→ Detected: RCMG19E0AB57
|
||||||
|
|
||||||
|
Step 2: "Type" [select-cards]
|
||||||
|
ONT / Routeur / Décodeur / Téléphone
|
||||||
|
→ Tap "ONT" → auto-advance
|
||||||
|
|
||||||
|
Step 3: "Confirmer" [info]
|
||||||
|
Serial: RCMG19E0AB57
|
||||||
|
Type: ONT
|
||||||
|
Client: Jean Tremblay (from job context)
|
||||||
|
Adresse: 123 rue Principale
|
||||||
|
[Lier à ce client]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Repair/Diagnostic Wizard
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: "Problème signalé" [info]
|
||||||
|
Ticket: "Pas d'internet depuis hier"
|
||||||
|
Client: Jean Tremblay
|
||||||
|
Adresse: 123 rue Principale
|
||||||
|
|
||||||
|
Step 2: "Signal ONT" [toggle + text]
|
||||||
|
Signal présent? [OUI / NON]
|
||||||
|
Niveau: [-XX dBm]
|
||||||
|
|
||||||
|
Step 3: "Test WiFi" [toggle]
|
||||||
|
WiFi fonctionne? [OUI / NON]
|
||||||
|
|
||||||
|
Step 4: "Action prise" [select-cards]
|
||||||
|
Redémarrage / Remplacement ONT / Remplacement routeur / Reconnexion fibre / Autre
|
||||||
|
|
||||||
|
Step 5: "Nouvel équipement" [scan] (if replacement)
|
||||||
|
Scanner le nouveau serial
|
||||||
|
|
||||||
|
Step 6: "Notes" [notes]
|
||||||
|
Détails supplémentaires...
|
||||||
|
|
||||||
|
Step 7: "Résultat" [toggle]
|
||||||
|
Service rétabli? [OUI / NON]
|
||||||
|
[Fermer le ticket]
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Form Customizer (Odoo Studio-like)
|
||||||
|
|
||||||
|
### Concept
|
||||||
|
|
||||||
|
An admin page in the Ops app (`/ops/form-builder`) that lets managers customize the tech wizard steps without code changes.
|
||||||
|
|
||||||
|
### How It Works
|
||||||
|
|
||||||
|
1. **Wizard Templates** stored as JSON documents in ERPNext (new doctype: `Wizard Template`)
|
||||||
|
2. **Each template** = ordered list of steps with field definitions
|
||||||
|
3. **Admin UI** = drag-and-drop step editor:
|
||||||
|
- Add step (from field type library)
|
||||||
|
- Reorder steps (drag handle)
|
||||||
|
- Edit step properties (label, options, required, visibility condition)
|
||||||
|
- Remove step
|
||||||
|
- Preview on mock phone screen
|
||||||
|
4. **Templates linked to job types**: Installation, Réparation, Maintenance, Retrait, etc.
|
||||||
|
5. **Field app fetches** the template for the current job type and renders it dynamically
|
||||||
|
|
||||||
|
### Wizard Template Doctype
|
||||||
|
|
||||||
|
```
|
||||||
|
Wizard Template
|
||||||
|
name: "Installation FTTH"
|
||||||
|
job_type: "Installation"
|
||||||
|
is_active: Check
|
||||||
|
steps: (child table: Wizard Template Step)
|
||||||
|
- step_order: 1
|
||||||
|
step_id: "equipment_checklist"
|
||||||
|
title: "Équipements installés"
|
||||||
|
field_type: "checklist"
|
||||||
|
field_config: '{"items":["ONT branché","Routeur configuré","Décodeur branché"]}'
|
||||||
|
required: 1
|
||||||
|
visible_condition: null
|
||||||
|
- step_order: 2
|
||||||
|
step_id: "ont_scan"
|
||||||
|
title: "Scanner ONT"
|
||||||
|
field_type: "scan"
|
||||||
|
field_config: '{"placeholder":"Scanner le code-barres ONT"}'
|
||||||
|
required: 1
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin Builder UI
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─ Form Builder: Installation FTTH ─────────────────────────┐
|
||||||
|
│ │
|
||||||
|
│ ┌─ Step 1 ─────────────────────────── [≡] [✏️] [🗑] ──┐ │
|
||||||
|
│ │ 📋 Checklist: "Équipements installés" │ │
|
||||||
|
│ │ Items: ONT, Routeur, Décodeur, Téléphone │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Step 2 ─────────────────────────── [≡] [✏️] [🗑] ──┐ │
|
||||||
|
│ │ 📷 Scan: "Scanner ONT" │ │
|
||||||
|
│ │ Required: Oui │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Step 3 ─────────────────────────── [≡] [✏️] [🗑] ──┐ │
|
||||||
|
│ │ ✅ Toggle: "Signal ONT OK?" │ │
|
||||||
|
│ └─────────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [+ Ajouter une étape] │
|
||||||
|
│ │
|
||||||
|
│ ┌─ Aperçu téléphone ──┐ │
|
||||||
|
│ │ ┌────────────────┐ │ │
|
||||||
|
│ │ │ Étape 1/7 │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ ☐ ONT branché │ │ │
|
||||||
|
│ │ │ ☐ Routeur │ │ │
|
||||||
|
│ │ │ ☐ Décodeur │ │ │
|
||||||
|
│ │ │ │ │ │
|
||||||
|
│ │ │ [Suivant →] │ │ │
|
||||||
|
│ │ │ ● ○ ○ ○ ○ ○ ○ │ │ │
|
||||||
|
│ │ └────────────────┘ │ │
|
||||||
|
│ └──────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ [Sauvegarder] [Publier] │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Equipment Swap Wizard (Defective → Replacement)
|
||||||
|
|
||||||
|
When tech taps "Remplacer" on a device or from the "Plus" menu:
|
||||||
|
|
||||||
|
```
|
||||||
|
Step 1: "Équipement défectueux" [search/scan]
|
||||||
|
Scan le code-barres de l'ancien équipement
|
||||||
|
→ OU chercher par numéro de série
|
||||||
|
→ Affiche: RCMG19E0AB57 — ONT Raisecom HT803G-WS2
|
||||||
|
Client: Jean Tremblay
|
||||||
|
Adresse: 123 rue Principale
|
||||||
|
|
||||||
|
Step 2: "Raison du remplacement" [select-cards]
|
||||||
|
🔴 Défectueux (ne s'allume plus)
|
||||||
|
🟡 Performance dégradée
|
||||||
|
🔵 Mise à niveau (upgrade)
|
||||||
|
⚪ Autre
|
||||||
|
|
||||||
|
Step 3: "Nouvel équipement" [scan]
|
||||||
|
📸 Scanner le code-barres du remplacement
|
||||||
|
→ Detected: TPLGA1E7FB90
|
||||||
|
→ Auto-detect type: XX230v (ONT)
|
||||||
|
|
||||||
|
Step 4: "Confirmer le remplacement" [info]
|
||||||
|
❌ Ancien: RCMG19E0AB57 → sera marqué "Défectueux"
|
||||||
|
✅ Nouveau: TPLGA1E7FB90 → sera activé
|
||||||
|
|
||||||
|
Données transférées automatiquement:
|
||||||
|
• WiFi SSID/mot de passe ✓
|
||||||
|
• SIP (VoIP) credentials ✓
|
||||||
|
• Port OLT: 0/3/2 ONT:12 ✓
|
||||||
|
• VLANs: inet/mgmt/tel/tv ✓
|
||||||
|
|
||||||
|
⚠️ L'OLT sera reconfiguré (ancien ONT supprimé, nouveau enregistré)
|
||||||
|
|
||||||
|
[Confirmer le remplacement]
|
||||||
|
|
||||||
|
Step 5: "Vérification" [toggle]
|
||||||
|
Nouveau ONT en ligne? [OUI / NON]
|
||||||
|
Signal OK? [OUI / NON]
|
||||||
|
Services fonctionnels? [OUI / NON]
|
||||||
|
|
||||||
|
[Terminer]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Backend flow (POST /provision/swap):**
|
||||||
|
1. Marks old equipment as "Défectueux" in ERPNext
|
||||||
|
2. Creates new Service Equipment with transferred WiFi/VoIP/OLT data
|
||||||
|
3. Generates OLT swap commands (delete old ONT, register new)
|
||||||
|
4. n8n executes OLT commands via SSH
|
||||||
|
5. ACS pushes config to new device on bootstrap
|
||||||
|
6. Ops team notified via SSE
|
||||||
|
|
||||||
|
### 5. Quick Actions Menu (field app "Plus" page)
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────┐
|
||||||
|
│ Actions rapides │
|
||||||
|
│ │
|
||||||
|
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ 📷 │ │ 🔄 │ │ 🔧 │ │
|
||||||
|
│ │ Scan │ │ Swap │ │ Diag │ │
|
||||||
|
│ │ équip. │ │ équip. │ │ réseau │ │
|
||||||
|
│ └────────┘ └────────┘ └────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────────┐ ┌────────┐ ┌────────┐ │
|
||||||
|
│ │ 📋 │ │ 📸 │ │ ✏️ │ │
|
||||||
|
│ │ Check │ │ Photo │ │ Note │ │
|
||||||
|
│ │ -list │ │ site │ │ rapide │ │
|
||||||
|
│ └────────┘ └────────┘ └────────┘ │
|
||||||
|
└──────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Each action opens the corresponding wizard flow.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Plan
|
||||||
|
|
||||||
|
### Phase 1: WizardCarousel Component
|
||||||
|
1. Create `apps/field/src/components/WizardCarousel.vue` — carousel renderer
|
||||||
|
2. Create `apps/field/src/components/wizard-fields/` — one component per field type:
|
||||||
|
- `SelectCards.vue` (big tappable cards)
|
||||||
|
- `ScanField.vue` (camera + barcode)
|
||||||
|
- `ToggleField.vue` (yes/no)
|
||||||
|
- `PhotoField.vue` (camera capture)
|
||||||
|
- `SignatureField.vue` (touch pad)
|
||||||
|
- `ChecklistField.vue` (checkable items)
|
||||||
|
- `TextField.vue` (single input)
|
||||||
|
- `NotesField.vue` (textarea)
|
||||||
|
- `InfoField.vue` (read-only summary)
|
||||||
|
3. Create `apps/field/src/composables/useWizard.js` — step state, validation, submission
|
||||||
|
|
||||||
|
### Phase 2: Hardcoded Wizards
|
||||||
|
1. Build "Installation Complete" wizard (7 steps as above)
|
||||||
|
2. Build "Equipment Scan & Link" wizard (3 steps)
|
||||||
|
3. Integrate into TasksPage "Complete Job" action
|
||||||
|
4. Replace current ScanPage equipment creation dialog with wizard flow
|
||||||
|
|
||||||
|
### Phase 3: Wizard Template Doctype
|
||||||
|
1. Create `Wizard Template` + `Wizard Template Step` doctypes in ERPNext
|
||||||
|
2. Seed with Installation, Repair, Maintenance templates
|
||||||
|
3. Field app fetches template by job_type on wizard open
|
||||||
|
4. WizardCarousel renders dynamically from template
|
||||||
|
|
||||||
|
### Phase 4: Admin Form Builder (Ops App)
|
||||||
|
1. Build `/ops/form-builder` page
|
||||||
|
2. Drag-and-drop step editor (vue-draggable)
|
||||||
|
3. Step property editor (sidebar panel)
|
||||||
|
4. Phone preview panel
|
||||||
|
5. Publish → saves to ERPNext Wizard Template
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## CSS / Styling Notes
|
||||||
|
|
||||||
|
```scss
|
||||||
|
// Wizard slide — full height, centered content
|
||||||
|
.wizard-slide {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 24px 16px;
|
||||||
|
min-height: calc(100vh - 160px); // account for header + progress bar
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select cards — large touch targets
|
||||||
|
.select-card {
|
||||||
|
min-height: 80px;
|
||||||
|
border-radius: 16px;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
transition: transform 0.15s, box-shadow 0.15s;
|
||||||
|
&:active { transform: scale(0.97); }
|
||||||
|
&.selected {
|
||||||
|
border: 3px solid var(--q-primary);
|
||||||
|
box-shadow: 0 0 0 4px rgba(var(--q-primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Progress dots
|
||||||
|
.wizard-dots {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 16px;
|
||||||
|
.dot {
|
||||||
|
width: 10px; height: 10px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ccc;
|
||||||
|
&.active { background: var(--q-primary); width: 24px; border-radius: 5px; }
|
||||||
|
&.done { background: var(--q-positive); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navigation buttons — thumb-friendly
|
||||||
|
.wizard-nav {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
.q-btn { min-height: 56px; font-size: 16px; flex: 1; border-radius: 12px; }
|
||||||
|
}
|
||||||
|
```
|
||||||
256
docs/LEGACY-FIELD-GAP-ANALYSIS.md
Normal file
256
docs/LEGACY-FIELD-GAP-ANALYSIS.md
Normal file
|
|
@ -0,0 +1,256 @@
|
||||||
|
# Legacy → ERPNext Field Gap Analysis
|
||||||
|
|
||||||
|
Complete audit of every legacy database field against current ERPNext doctypes.
|
||||||
|
Goal: **Zero data loss** — every meaningful field must have a home in ERPNext.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Customer (legacy `account` table)
|
||||||
|
|
||||||
|
### Already Migrated
|
||||||
|
| Legacy Field | ERPNext Field | Doctype | Script |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | legacy_account_id | Customer | migrate_direct.py |
|
||||||
|
| first_name + last_name | customer_name | Customer | migrate_direct.py |
|
||||||
|
| email | email_billing | Customer (custom) | import_customer_details.py |
|
||||||
|
| customer_id | legacy_customer_id | Customer | migrate_direct.py |
|
||||||
|
| address1, city, state, zip | address_line, city, province, postal_code | Service Location | migrate_locations.py |
|
||||||
|
| invoice_delivery | invoice_delivery_method | Customer (custom) | import_customer_details.py |
|
||||||
|
| commercial | is_commercial | Customer (custom) | import_customer_details.py |
|
||||||
|
| mauvais_payeur | is_bad_payer | Customer (custom) | import_customer_details.py |
|
||||||
|
| tax_group | tax_category_legacy | Customer (custom) | import_customer_details.py |
|
||||||
|
| contact | contact_name_legacy | Customer (custom) | import_customer_details.py |
|
||||||
|
| mandataire | mandataire | Customer (custom) | import_customer_details.py |
|
||||||
|
| tel_home | tel_home | Customer (custom) | import_customer_details.py |
|
||||||
|
| tel_office | tel_office | Customer (custom) | import_customer_details.py |
|
||||||
|
| cell | cell_phone | Customer (custom) | import_customer_details.py |
|
||||||
|
| fax | fax | Customer (custom) | import_customer_details.py |
|
||||||
|
| misc | notes_internal | Customer (custom) | import_customer_details.py |
|
||||||
|
| frais | exclude_fees | Customer (custom) | import_customer_details.py |
|
||||||
|
| email_autre | email_publipostage | Customer (custom) | import_customer_details.py |
|
||||||
|
| date_orig | date_created_legacy | Customer (custom) | import_customer_details.py |
|
||||||
|
|
||||||
|
### GAP — Missing Fields (need custom fields + migration)
|
||||||
|
| Legacy Field | Values/Stats | Proposed ERPNext Field | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| middle_name | Free text | `middle_name` Data | LOW |
|
||||||
|
| title | "M.", "Mme", etc. | `salutation_legacy` Data | LOW |
|
||||||
|
| ppa | 1,249 accounts | `ppa_enabled` Check | HIGH |
|
||||||
|
| ppa_name | Payer name | `ppa_name` Data | HIGH |
|
||||||
|
| ppa_code | Bank transit | `ppa_code` Data | HIGH |
|
||||||
|
| ppa_branch | Bank branch | `ppa_branch` Data | HIGH |
|
||||||
|
| ppa_account | Bank account | `ppa_account` Data | HIGH |
|
||||||
|
| ppa_amount | Amount | `ppa_amount` Currency | HIGH |
|
||||||
|
| ppa_amount_buffer | Buffer | `ppa_amount_buffer` Currency | MEDIUM |
|
||||||
|
| ppa_fixed | Fixed payment flag | `ppa_fixed` Check | MEDIUM |
|
||||||
|
| ppa_cc | PPA via credit card | `ppa_cc` Check | MEDIUM |
|
||||||
|
| ppa_all_invoice | Apply to all invoices | `ppa_all_invoice` Check | MEDIUM |
|
||||||
|
| vip | 28 accounts | `is_vip` Check | MEDIUM |
|
||||||
|
| stripe_id | 785 accounts | `stripe_customer_id` Data | HIGH |
|
||||||
|
| stripe_ppa | Stripe auto-pay flag | `stripe_ppa_enabled` Check | HIGH |
|
||||||
|
| land_owner | Property owner flag | `is_land_owner` Check | LOW |
|
||||||
|
| keyword | Search keyword | `search_keyword` Data | LOW |
|
||||||
|
| pub | Marketing opt-in | `marketing_optin` Check | MEDIUM |
|
||||||
|
| call | Call contact flag | `call_contact` Check | LOW |
|
||||||
|
| username | Portal login | `portal_username` Data | LOW |
|
||||||
|
| password | Portal password (MD5) | `portal_password_hash` Data | LOW |
|
||||||
|
| terminate_reason | Reason for termination | `terminate_reason` Small Text | MEDIUM |
|
||||||
|
| terminate_cie | Competitor they left for | `terminate_cie` Data | MEDIUM |
|
||||||
|
| terminate_note | Termination notes | `terminate_note` Small Text | MEDIUM |
|
||||||
|
| terminate_date | Date terminated | `terminate_date` Date | MEDIUM |
|
||||||
|
| notes_client | Client-visible notes | `notes_client` Small Text | MEDIUM |
|
||||||
|
| address2 | Address line 2 | *Already in Service Location* | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Service Subscription (legacy `service` table)
|
||||||
|
|
||||||
|
### Already Migrated
|
||||||
|
| Legacy Field | ERPNext Field | Script |
|
||||||
|
|---|---|---|
|
||||||
|
| id | legacy_service_id | import_services_and_enrich_customers.py |
|
||||||
|
| delivery_id | service_location (via map) | import_services_and_enrich_customers.py |
|
||||||
|
| device_id | device (Link to Service Equipment) | import_services_and_enrich_customers.py |
|
||||||
|
| product_id | product_sku | import_services_and_enrich_customers.py |
|
||||||
|
| status | status (mapped to Actif/Suspendu/Annulé) | import_services_and_enrich_customers.py |
|
||||||
|
| comment | notes | import_services_and_enrich_customers.py |
|
||||||
|
| payment_recurrence | billing_cycle | import_services_and_enrich_customers.py |
|
||||||
|
| hijack_price | monthly_price (when hijack=1) | import_services_and_enrich_customers.py |
|
||||||
|
| hijack_download_speed | speed_down (when hijack=1) | import_services_and_enrich_customers.py |
|
||||||
|
| hijack_upload_speed | speed_up (when hijack=1) | import_services_and_enrich_customers.py |
|
||||||
|
| date_orig | start_date | import_services_and_enrich_customers.py |
|
||||||
|
| date_end_contract | end_date | import_services_and_enrich_customers.py |
|
||||||
|
| radius_user | radius_user | import_services_and_enrich_customers.py |
|
||||||
|
| radius_pwd | radius_password | import_services_and_enrich_customers.py |
|
||||||
|
|
||||||
|
### GAP — Missing Fields
|
||||||
|
| Legacy Field | Values/Stats | Proposed ERPNext Field | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| hijack | Override flag (bool) | `is_custom_pricing` Check | MEDIUM |
|
||||||
|
| hijack_desc | Override description | `custom_pricing_desc` Data | MEDIUM |
|
||||||
|
| hijack_quota_day | Day bandwidth quota | `quota_day_gb` Float | LOW |
|
||||||
|
| hijack_quota_night | Night bandwidth quota | `quota_night_gb` Float | LOW |
|
||||||
|
| date_suspended | Suspension date | `date_suspended` Date | HIGH |
|
||||||
|
| actif_until | Active-until date | `active_until` Date | MEDIUM |
|
||||||
|
| date_next_invoice | Next invoice date | `next_invoice_date` Date | HIGH |
|
||||||
|
| forfait_internet | Internet bundle flag | `forfait_internet` Check | LOW |
|
||||||
|
| radius_conso | RADIUS consumption tracking | `radius_consumption` Data | LOW |
|
||||||
|
| ip_fixe | Static IP address | `static_ip` Data | MEDIUM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Service Equipment (legacy `device` table)
|
||||||
|
|
||||||
|
### Already Migrated
|
||||||
|
| Legacy Field | ERPNext Field | Script |
|
||||||
|
|---|---|---|
|
||||||
|
| id | legacy_device_id | import_devices_and_enrich.py |
|
||||||
|
| delivery_id | service_location (via map) | import_devices_and_enrich.py |
|
||||||
|
| category | equipment_type (mapped) | import_devices_and_enrich.py |
|
||||||
|
| sn | serial_number | import_devices_and_enrich.py |
|
||||||
|
| mac | mac_address | import_devices_and_enrich.py |
|
||||||
|
| manufacturier | brand | import_devices_and_enrich.py |
|
||||||
|
| model | model | import_devices_and_enrich.py |
|
||||||
|
| manage / manage_cli | ip_address | import_devices_and_enrich.py |
|
||||||
|
| user | login_user | import_devices_and_enrich.py |
|
||||||
|
| pass | login_password | import_devices_and_enrich.py |
|
||||||
|
|
||||||
|
### GAP — Missing Fields
|
||||||
|
| Legacy Field | Values/Stats | Proposed ERPNext Field | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| manage | Full management URL/IP | `manage_url` Data | HIGH |
|
||||||
|
| port | Management port | `manage_port` Int | HIGH |
|
||||||
|
| protocol | Management protocol (http/https/ssh) | `manage_protocol` Select | HIGH |
|
||||||
|
| manage_cli | CLI access IP | `cli_ip` Data | HIGH |
|
||||||
|
| port_cli | CLI port | `cli_port` Int | HIGH |
|
||||||
|
| protocol_cli | CLI protocol (ssh/telnet) | `cli_protocol` Select | HIGH |
|
||||||
|
| parent | Parent device ID | `parent_device` Link (Service Equipment) | HIGH |
|
||||||
|
| category | Exact legacy category string | `legacy_category` Data | MEDIUM |
|
||||||
|
| name (legacy) | Device display name | `device_name` Data | LOW |
|
||||||
|
|
||||||
|
### GAP — Provisioning Fields (from GenieACS MariaDB)
|
||||||
|
| Source | Field | Proposed ERPNext Field | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GenieACS wifi table | ssid | `wifi_ssid` Data | HIGH |
|
||||||
|
| GenieACS wifi table | password | `wifi_password` Password | HIGH |
|
||||||
|
| GenieACS voip table | username | `sip_username` Data | HIGH |
|
||||||
|
| GenieACS voip table | password | `sip_password` Password | HIGH |
|
||||||
|
| GenieACS devices | CWMP serial | `cwmp_serial` Data | HIGH |
|
||||||
|
| GenieACS devices | GPON serial | `gpon_serial` Data | HIGH |
|
||||||
|
| Fibre table | line_profile | `fibre_line_profile` Data | MEDIUM |
|
||||||
|
| Fibre table | service_profile | `fibre_service_profile` Data | MEDIUM |
|
||||||
|
|
||||||
|
### GAP — ACS Integration Fields
|
||||||
|
| Purpose | Proposed ERPNext Field | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| GenieACS/Oktopus device ID | `acs_device_id` Data | HIGH |
|
||||||
|
| Last ACS inform time | `acs_last_inform` Datetime | MEDIUM |
|
||||||
|
| ACS online status | `acs_online` Check | MEDIUM |
|
||||||
|
| WAN IP from ACS | `wan_ip` Data | MEDIUM |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Service Location (legacy `delivery` + `fibre` tables)
|
||||||
|
|
||||||
|
### Already Migrated
|
||||||
|
| Legacy Field | ERPNext Field | Script |
|
||||||
|
|---|---|---|
|
||||||
|
| delivery.id | legacy_delivery_id | migrate_locations.py |
|
||||||
|
| delivery.address1 | address_line | migrate_locations.py |
|
||||||
|
| delivery.city | city | migrate_locations.py |
|
||||||
|
| delivery.state | province | migrate_locations.py |
|
||||||
|
| delivery.zip | postal_code | migrate_locations.py |
|
||||||
|
| delivery.account_id | customer (via map) | migrate_locations.py |
|
||||||
|
| fibre.frame/slot/port/ontid | olt_port | import_devices_and_enrich.py |
|
||||||
|
| fibre.vlan_* | network_id (concatenated) | import_devices_and_enrich.py |
|
||||||
|
| *connection_type inferred* | connection_type | import_devices_and_enrich.py |
|
||||||
|
|
||||||
|
### GAP — Missing Fields from `delivery`
|
||||||
|
| Legacy Field | Proposed ERPNext Field | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| address2 | `address_line_2` Data | MEDIUM |
|
||||||
|
| contact | `contact_name` (already exists) | — |
|
||||||
|
| phone | `contact_phone` (already exists) | — |
|
||||||
|
| note | `delivery_notes` Small Text | MEDIUM |
|
||||||
|
| appartement | `apartment_number` Data | MEDIUM |
|
||||||
|
|
||||||
|
### GAP — Missing Fields from `fibre`
|
||||||
|
| Legacy Field | Values/Stats | Proposed ERPNext Field | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| id | 16,056 entries | `legacy_fibre_id` Int | MEDIUM |
|
||||||
|
| sn | ONT serial number | `ont_serial` Data | HIGH |
|
||||||
|
| olt_ip (info_connect) | OLT management IP | `olt_ip` Data | HIGH |
|
||||||
|
| olt_name (from fibre_olt) | OLT display name | `olt_name` Data | MEDIUM |
|
||||||
|
| ontid | ONT ID on OLT port | `ont_id` Int | HIGH |
|
||||||
|
| terrain | Property type | `terrain_type` Data | LOW |
|
||||||
|
| distance | Distance from OLT | `fibre_distance_m` Float | LOW |
|
||||||
|
| nb_portees | Number of spans | `fibre_spans` Int | LOW |
|
||||||
|
| temps_estim | Estimated install time | `install_time_estimate` Data | LOW |
|
||||||
|
| suite | Apartment indicator | `is_apartment` Check | LOW |
|
||||||
|
| boitier_pas_install | Box not installed flag | `box_not_installed` Check | LOW |
|
||||||
|
| vlan_manage | Management VLAN (individual) | `vlan_manage` Int | MEDIUM |
|
||||||
|
| vlan_internet | Internet VLAN | `vlan_internet` Int | MEDIUM |
|
||||||
|
| vlan_telephone | Telephone VLAN | `vlan_telephone` Int | MEDIUM |
|
||||||
|
| vlan_tele | TV VLAN | `vlan_tv` Int | MEDIUM |
|
||||||
|
| manage_service_id | Mgmt service link | `manage_service_id` Int | LOW |
|
||||||
|
| internet_service_id | Internet service link | `internet_service_id` Int | LOW |
|
||||||
|
| telephone_service_id | Phone service link | `telephone_service_id` Int | LOW |
|
||||||
|
| tele_service_id | TV service link | `tv_service_id` Int | LOW |
|
||||||
|
| placemarks_id | Map placement ID | `placemarks_id` Data | LOW |
|
||||||
|
| appartements_id | Apartment building ID | `apartment_building_id` Data | LOW |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. device_attr (key-value pairs, 497 entries)
|
||||||
|
|
||||||
|
No ERPNext equivalent exists. Contains per-device extended attributes:
|
||||||
|
- **MAC addresses per interface** (eth0 through eth5) — multiple MACs per device
|
||||||
|
- **stb_id** — Ministra/IPTV subscription ID
|
||||||
|
- **Custom OLT references**
|
||||||
|
- **Router routes** (static routes configured on device)
|
||||||
|
|
||||||
|
### Proposed Solution
|
||||||
|
| Attribute Key | Proposed ERPNext Field | On Doctype | Priority |
|
||||||
|
|---|---|---|---|
|
||||||
|
| mac_ethX | `mac_addresses_json` Long Text (JSON) | Service Equipment | MEDIUM |
|
||||||
|
| stb_id | `iptv_subscription_id` Data | Service Equipment | HIGH |
|
||||||
|
| OLT refs | Already captured via fibre table | Service Location | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Dispatch Job (legacy `bon_travail` table)
|
||||||
|
|
||||||
|
### Already Migrated
|
||||||
|
Dispatch Job doctype exists with custom FSM fields. However:
|
||||||
|
|
||||||
|
### GAP — Missing Fields from `bon_travail`
|
||||||
|
| Legacy Field | Proposed ERPNext Field | Priority |
|
||||||
|
|---|---|---|
|
||||||
|
| tech1_id | Already `assigned_to` on Dispatch Job | — |
|
||||||
|
| tech2_id | `second_technician` Data | MEDIUM |
|
||||||
|
| tech1_arrive | `tech1_arrival` Datetime | MEDIUM |
|
||||||
|
| tech1_depart | `tech1_departure` Datetime | MEDIUM |
|
||||||
|
| tech2_arrive | `tech2_arrival` Datetime | MEDIUM |
|
||||||
|
| tech2_depart | `tech2_departure` Datetime | MEDIUM |
|
||||||
|
| line items (bon_travail_item) | Already `equipment_items` + `materials_used` child tables | — |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary: Custom Fields to Add
|
||||||
|
|
||||||
|
### Customer — 23 new fields
|
||||||
|
PPA section (8), Stripe (2), Termination (4), VIP/flags (3), Portal (2), Other (4)
|
||||||
|
|
||||||
|
### Service Subscription — 10 new fields
|
||||||
|
Custom pricing (3), Dates (3), Quotas (2), IP (1), Other (1)
|
||||||
|
|
||||||
|
### Service Equipment — 17 new fields
|
||||||
|
Management (6), Provisioning (8), ACS (4), Legacy (1)
|
||||||
|
|
||||||
|
### Service Location — 18 new fields
|
||||||
|
Fibre infrastructure (10), VLANs (4), Legacy IDs (4)
|
||||||
|
|
||||||
|
### Dispatch Job — 5 new fields
|
||||||
|
Second tech + time tracking (5)
|
||||||
|
|
||||||
|
**Total: ~73 custom fields across 5 doctypes**
|
||||||
331
docs/XX230V-DIAGNOSTICS-AND-OKTOPUS-TEST.md
Normal file
331
docs/XX230V-DIAGNOSTICS-AND-OKTOPUS-TEST.md
Normal file
|
|
@ -0,0 +1,331 @@
|
||||||
|
# XX230v Diagnostic Gaps & Oktopus Test Plan
|
||||||
|
|
||||||
|
## The Problem
|
||||||
|
|
||||||
|
When a customer reports WiFi issues, the tech currently has almost no data to diagnose whether the problem is:
|
||||||
|
- **The XX230v ONT** (GPON side failing, internal routing broken)
|
||||||
|
- **The Deco mesh** (WiFi radio issue, coverage gap)
|
||||||
|
- **The fibre itself** (signal degradation, dirty connector)
|
||||||
|
- **The customer's device** (driver issue, band-steering problem)
|
||||||
|
|
||||||
|
The current GenieACS provision (`xx230v_inform`) only reads:
|
||||||
|
- GPON serial, MAC, uptime
|
||||||
|
- WiFi SSID list (names only, no signal/client data)
|
||||||
|
- Connected hosts (cleared every inform! → useless for diagnosis)
|
||||||
|
|
||||||
|
**We're blind.** The XX230v exposes a rich TR-181 data model that we're not reading.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What TR-181 Parameters the XX230v Actually Has
|
||||||
|
|
||||||
|
The XX230v (TP-Link Deco XE75 ONT variant) supports the full TR-181:2 data model. Here's what we SHOULD be reading but currently aren't:
|
||||||
|
|
||||||
|
### 1. Optical Signal (fibre health)
|
||||||
|
```
|
||||||
|
Device.Optical.Interface.1.Status → Up/Down
|
||||||
|
Device.Optical.Interface.1.Stats.SignalRxPower → dBm (target: -8 to -25)
|
||||||
|
Device.Optical.Interface.1.Stats.SignalTxPower → dBm
|
||||||
|
Device.Optical.Interface.1.Stats.BytesSent
|
||||||
|
Device.Optical.Interface.1.Stats.BytesReceived
|
||||||
|
Device.Optical.Interface.1.Stats.ErrorsSent
|
||||||
|
Device.Optical.Interface.1.Stats.ErrorsReceived
|
||||||
|
Device.Optical.Interface.1.Stats.DiscardPacketsSent
|
||||||
|
Device.Optical.Interface.1.Stats.DiscardPacketsReceived
|
||||||
|
Device.Optical.Interface.1.LowerLayers → underlying interface
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis power:** If RxPower < -25 dBm → fibre problem (dirty connector, bend, break). If errors high → OLT port issue or fibre degradation. No need to swap the ONT.
|
||||||
|
|
||||||
|
### 2. WiFi Radios (per-band stats)
|
||||||
|
```
|
||||||
|
Device.WiFi.Radio.1.Status → Up/Down (2.4GHz)
|
||||||
|
Device.WiFi.Radio.1.Channel
|
||||||
|
Device.WiFi.Radio.1.OperatingFrequencyBand → 2.4GHz / 5GHz / 6GHz
|
||||||
|
Device.WiFi.Radio.1.CurrentOperatingChannelBandwidth → 20MHz/40MHz/80MHz/160MHz
|
||||||
|
Device.WiFi.Radio.1.Stats.Noise → dBm (background noise)
|
||||||
|
Device.WiFi.Radio.1.Stats.BytesSent
|
||||||
|
Device.WiFi.Radio.1.Stats.BytesReceived
|
||||||
|
Device.WiFi.Radio.1.Stats.ErrorsSent
|
||||||
|
Device.WiFi.Radio.1.Stats.ErrorsReceived
|
||||||
|
Device.WiFi.Radio.1.Stats.PacketsSent
|
||||||
|
Device.WiFi.Radio.1.Stats.PacketsReceived
|
||||||
|
|
||||||
|
Device.WiFi.Radio.2.* → 5GHz band
|
||||||
|
Device.WiFi.Radio.3.* → 6GHz band (XE75 has WiFi 6E)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis power:** High noise + errors on a specific band → channel congestion (not a hardware fault). Channel 1/6/11 comparison shows interference.
|
||||||
|
|
||||||
|
### 3. WiFi Access Points & Connected Clients
|
||||||
|
```
|
||||||
|
Device.WiFi.AccessPoint.1.AssociatedDevice.{i}.
|
||||||
|
MACAddress → client MAC
|
||||||
|
SignalStrength → dBm (how well client hears the AP)
|
||||||
|
Noise → dBm (noise floor at client)
|
||||||
|
Retransmissions → high = poor signal quality
|
||||||
|
Active → currently connected?
|
||||||
|
LastDataDownlinkRate → actual PHY rate to client (Mbps)
|
||||||
|
LastDataUplinkRate → actual PHY rate from client (Mbps)
|
||||||
|
OperatingStandard → ax/ac/n/g (WiFi generation)
|
||||||
|
|
||||||
|
Device.WiFi.AccessPoint.1.
|
||||||
|
AssociatedDeviceNumberOfEntries → client count
|
||||||
|
Status → Enabled/Disabled
|
||||||
|
SSIDAdvertisementEnabled → SSID visible?
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis power:** If client SignalStrength > -65 dBm but Retransmissions high → interference. If LastDataDownlinkRate < 50 Mbps on WiFi 6 → something wrong with negotiation. If 0 clients on 5GHz → band steering issue.
|
||||||
|
|
||||||
|
### 4. Deco Mesh Topology (MultiAP / EasyMesh)
|
||||||
|
```
|
||||||
|
Device.WiFi.MultiAP.APDevice.{i}.
|
||||||
|
MACAddress → mesh node MAC
|
||||||
|
Manufacturer
|
||||||
|
SerialNumber
|
||||||
|
Radio.{j}.
|
||||||
|
Noise
|
||||||
|
Utilization → % channel busy
|
||||||
|
AP.{k}.
|
||||||
|
SSID
|
||||||
|
UnicastBytesSent
|
||||||
|
AssociatedDevice.{l}.
|
||||||
|
MACAddress
|
||||||
|
SignalStrength
|
||||||
|
LastDataDownlinkRate
|
||||||
|
|
||||||
|
Device.WiFi.DataElements.Network.Device.{i}.
|
||||||
|
ID → mesh node ID
|
||||||
|
MultiAPCapabilities
|
||||||
|
Radio.{j}.
|
||||||
|
CurrentOperatingClassProfile
|
||||||
|
UnassociatedSTA.{k}. → devices seen but not connected
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis power:** Shows which Deco node each client is connected to, signal quality per-node, backhaul utilization. If satellite Deco has poor backhaul → move it closer or add wired backhaul.
|
||||||
|
|
||||||
|
### 5. IP Diagnostics (built-in speed/ping test)
|
||||||
|
```
|
||||||
|
Device.IP.Diagnostics.IPPing.
|
||||||
|
Host → target to ping
|
||||||
|
NumberOfRepetitions
|
||||||
|
Timeout
|
||||||
|
DataBlockSize
|
||||||
|
DiagnosticsState → set to "Requested" to trigger
|
||||||
|
→ Results:
|
||||||
|
SuccessCount, FailureCount
|
||||||
|
AverageResponseTime
|
||||||
|
MinimumResponseTime
|
||||||
|
MaximumResponseTime
|
||||||
|
|
||||||
|
Device.IP.Diagnostics.DownloadDiagnostics.
|
||||||
|
DownloadURL → URL to download from
|
||||||
|
DiagnosticsState → "Requested" to trigger
|
||||||
|
→ Results:
|
||||||
|
ROMTime, BOMTime, EOMTime
|
||||||
|
TestBytesReceived
|
||||||
|
TotalBytesReceived
|
||||||
|
→ Calculate: speed = TestBytesReceived / (EOMTime - BOMTime)
|
||||||
|
|
||||||
|
Device.IP.Diagnostics.UploadDiagnostics.
|
||||||
|
UploadURL → URL to upload to
|
||||||
|
DiagnosticsState → "Requested" to trigger
|
||||||
|
→ Results: same pattern as download
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis power:** Remote speed test FROM the ONT itself. Eliminates WiFi as a variable. If ONT→server speed is good but client speed is bad → WiFi issue. If ONT→server speed is bad → fibre/OLT issue.
|
||||||
|
|
||||||
|
### 6. Ethernet Ports
|
||||||
|
```
|
||||||
|
Device.Ethernet.Interface.{i}.
|
||||||
|
Status → Up/Down
|
||||||
|
MACAddress
|
||||||
|
MaxBitRate → negotiated link speed (100/1000/2500)
|
||||||
|
DuplexMode → Full/Half
|
||||||
|
Stats.BytesSent/Received
|
||||||
|
Stats.ErrorsSent/Received
|
||||||
|
Stats.PacketsSent/Received
|
||||||
|
```
|
||||||
|
|
||||||
|
**Diagnosis power:** If Ethernet negotiated at 100Mbps instead of 1Gbps → bad cable or port. If errors high → physical layer issue.
|
||||||
|
|
||||||
|
### 7. DNS & Routing
|
||||||
|
```
|
||||||
|
Device.DNS.Client.Server.{i}.
|
||||||
|
DNSServer → configured DNS
|
||||||
|
Status
|
||||||
|
Type → DHCPv4 / Static
|
||||||
|
|
||||||
|
Device.Routing.Router.1.IPv4Forwarding.{i}.
|
||||||
|
DestIPAddress
|
||||||
|
GatewayIPAddress
|
||||||
|
Interface
|
||||||
|
Status
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## What to Add to GenieACS Inform (Immediate)
|
||||||
|
|
||||||
|
Update the `xx230v_inform` provision to read diagnostic data on every inform:
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// === DIAGNOSTIC DATA (add to xx230v_inform) ===
|
||||||
|
|
||||||
|
// Optical signal — THE most important diagnostic
|
||||||
|
declare("Device.Optical.Interface.1.Status", {value: now});
|
||||||
|
declare("Device.Optical.Interface.1.Stats.SignalRxPower", {value: now});
|
||||||
|
declare("Device.Optical.Interface.1.Stats.SignalTxPower", {value: now});
|
||||||
|
declare("Device.Optical.Interface.1.Stats.ErrorsSent", {value: now});
|
||||||
|
declare("Device.Optical.Interface.1.Stats.ErrorsReceived", {value: now});
|
||||||
|
|
||||||
|
// WiFi radio stats (per band)
|
||||||
|
declare("Device.WiFi.Radio.1.Status", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.1.Channel", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.1.CurrentOperatingChannelBandwidth", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.1.Stats.Noise", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.1.Stats.ErrorsSent", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.2.Status", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.2.Channel", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.2.CurrentOperatingChannelBandwidth", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.2.Stats.Noise", {value: now});
|
||||||
|
declare("Device.WiFi.Radio.3.Status", {value: now}); // 6GHz if supported
|
||||||
|
declare("Device.WiFi.Radio.3.Channel", {value: now});
|
||||||
|
|
||||||
|
// Connected clients count per AP
|
||||||
|
declare("Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries", {value: now});
|
||||||
|
declare("Device.WiFi.AccessPoint.2.AssociatedDeviceNumberOfEntries", {value: now});
|
||||||
|
declare("Device.WiFi.AccessPoint.3.AssociatedDeviceNumberOfEntries", {value: now});
|
||||||
|
|
||||||
|
// WAN IP (already partially read)
|
||||||
|
declare("Device.IP.Interface.1.IPv4Address.1.IPAddress", {value: now});
|
||||||
|
|
||||||
|
// Ethernet port status
|
||||||
|
declare("Device.Ethernet.Interface.1.Status", {value: now});
|
||||||
|
declare("Device.Ethernet.Interface.1.MaxBitRate", {value: now});
|
||||||
|
declare("Device.Ethernet.Interface.2.Status", {value: now});
|
||||||
|
declare("Device.Ethernet.Interface.2.MaxBitRate", {value: now});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Equipment Swap Status — Fix the "Not Sure It's Defective" Problem
|
||||||
|
|
||||||
|
Instead of immediately marking as "Défectueux", the swap flow should support **diagnostic swap**:
|
||||||
|
|
||||||
|
### Updated Status Flow
|
||||||
|
```
|
||||||
|
Normal equipment statuses:
|
||||||
|
En inventaire → Actif → [issue reported] →
|
||||||
|
|
||||||
|
Option A: "En diagnostic" ← tech swaps to test, original goes back to warehouse
|
||||||
|
Option B: "Défectueux" ← confirmed dead
|
||||||
|
Option C: "Retourné" ← returned to stock after test (was fine)
|
||||||
|
|
||||||
|
Swap types:
|
||||||
|
1. "Remplacement définitif" → old = Défectueux, new = Actif
|
||||||
|
2. "Swap diagnostic" → old = En diagnostic, new = Actif (temporary)
|
||||||
|
3. "Retour de diagnostic" → old = Actif (put back), temp = Retourné
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated Wizard Step 2 (Reason)
|
||||||
|
```
|
||||||
|
Step 2: "Type de remplacement" [select-cards]
|
||||||
|
🔴 Remplacement définitif — L'équipement est mort
|
||||||
|
🟡 Swap diagnostic — Tester si le problème vient de l'équipement
|
||||||
|
🔵 Mise à niveau — Remplacer par un modèle supérieur
|
||||||
|
```
|
||||||
|
|
||||||
|
If "Swap diagnostic" is chosen:
|
||||||
|
- Old equipment → status "En diagnostic" (not Défectueux)
|
||||||
|
- A follow-up task is auto-created: "Diagnostic result for {serial}"
|
||||||
|
- Due in 7 days
|
||||||
|
- Options: "Confirmed defective" → mark Défectueux | "Works fine" → mark Retourné
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Oktopus Test Plan — What to Verify
|
||||||
|
|
||||||
|
### Step 1: Deploy Oktopus with TR-069 Adapter
|
||||||
|
|
||||||
|
Oktopus is already at `oss.gigafibre.ca` with 8 containers. We need to:
|
||||||
|
|
||||||
|
1. **Verify it's running**: `curl https://oss.gigafibre.ca/api/health`
|
||||||
|
2. **Check the TR-069 adapter port**: Oktopus listens for CWMP connections (typically 7547)
|
||||||
|
3. **Check the admin UI**: Oktopus CE has a web dashboard
|
||||||
|
|
||||||
|
### Step 2: Point ONE Test XX230v to Oktopus
|
||||||
|
|
||||||
|
On GenieACS, push a parameter change to a single test device:
|
||||||
|
```
|
||||||
|
Device.ManagementServer.URL = "https://acs-new.gigafibre.ca:7547"
|
||||||
|
```
|
||||||
|
|
||||||
|
Or physically: configure the ACS URL in the XX230v admin panel.
|
||||||
|
|
||||||
|
### Step 3: What to Check on Oktopus
|
||||||
|
|
||||||
|
Once the XX230v connects to Oktopus:
|
||||||
|
|
||||||
|
| Check | What to Look For | Why It Matters |
|
||||||
|
|-------|-----------------|----------------|
|
||||||
|
| Device appears | Does Oktopus see the TR-069 inform? | Basic connectivity |
|
||||||
|
| Data model | Does it show the full TR-181 tree? | TR-181 paths are the same |
|
||||||
|
| Parameter read | Can we read `Device.Optical.Interface.1.Stats.SignalRxPower`? | Real-time diagnostics |
|
||||||
|
| Parameter set | Can we push WiFi SSID/password? | Provisioning works |
|
||||||
|
| Reboot | Can we trigger a remote reboot? | Basic management |
|
||||||
|
| Firmware | Can we push a firmware update? | Fleet management |
|
||||||
|
| Tasks | Can we queue tasks for next inform? | Offline task queue |
|
||||||
|
| Webhooks | Does Oktopus fire webhooks on events? | n8n integration |
|
||||||
|
| Multiple devices | Can it handle the full fleet? | Scalability |
|
||||||
|
|
||||||
|
### Step 4: TR-369 (USP) Check
|
||||||
|
|
||||||
|
If the XX230v firmware supports USP (check TP-Link release notes for your version):
|
||||||
|
```
|
||||||
|
Device.LocalAgent.Controller.{i}.
|
||||||
|
EndpointID
|
||||||
|
Protocol → MQTT / WebSocket
|
||||||
|
MTP.{j}.
|
||||||
|
Enable
|
||||||
|
Protocol
|
||||||
|
MQTT.Topic
|
||||||
|
```
|
||||||
|
|
||||||
|
If USP is available → configure Oktopus as USP controller → real-time bidirectional management (no more polling!).
|
||||||
|
|
||||||
|
### Step 5: Compare GenieACS vs Oktopus Data
|
||||||
|
|
||||||
|
Run the same device through both ACS simultaneously (different inform intervals) and compare:
|
||||||
|
|
||||||
|
| Aspect | GenieACS | Oktopus | Notes |
|
||||||
|
|--------|----------|---------|-------|
|
||||||
|
| Inform data | _lastInform, basic params | ? | Same TR-181 paths |
|
||||||
|
| WiFi clients | Cleared by provision (broken!) | ? | Don't clear in Oktopus |
|
||||||
|
| Optical power | Only via summarizeDevice() | ? | Should be real-time |
|
||||||
|
| Remote diagnostics | Via NBI task push | Via USP Operate | USP is synchronous |
|
||||||
|
| Webhook events | None (we poll) | Built-in | Major improvement |
|
||||||
|
| Firmware mgmt | GridFS upload + provision | Native firmware repo | Cleaner |
|
||||||
|
| Bulk operations | JS provisions | Device groups + policies | More scalable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Immediate Actions
|
||||||
|
|
||||||
|
### 1. Update xx230v_inform provision (15 min)
|
||||||
|
Add the diagnostic parameters listed above. Deploys instantly to all XX230v fleet via GenieACS.
|
||||||
|
|
||||||
|
### 2. Update summarizeDevice() in targo-hub (15 min)
|
||||||
|
Add optical signal, WiFi radio stats, client counts, Ethernet link speed to the device summary.
|
||||||
|
|
||||||
|
### 3. Update Ops app device detail view (30 min)
|
||||||
|
Show the new diagnostic data in the device panel (Optical power, WiFi channels, client count per band).
|
||||||
|
|
||||||
|
### 4. SSH to Oktopus server, verify it's running (10 min)
|
||||||
|
Check `docker compose ps` at `/opt/oktopus/`, verify API responds, check admin UI.
|
||||||
|
|
||||||
|
### 5. Point one test XX230v to Oktopus (10 min)
|
||||||
|
Via GenieACS: set `Device.ManagementServer.URL` on a single device.
|
||||||
|
|
||||||
|
### 6. Add "En diagnostic" status to Service Equipment (5 min)
|
||||||
|
Add the new status option + update swap flow logic.
|
||||||
|
|
@ -160,7 +160,7 @@ def _create_service_equipment():
|
||||||
"options": "Service Location"},
|
"options": "Service Location"},
|
||||||
{"fieldname": "col_status", "fieldtype": "Column Break"},
|
{"fieldname": "col_status", "fieldtype": "Column Break"},
|
||||||
{"fieldname": "status", "fieldtype": "Select", "label": "Statut",
|
{"fieldname": "status", "fieldtype": "Select", "label": "Statut",
|
||||||
"options": "En inventaire\nActif\nDéfectueux\nRetourné\nPerdu",
|
"options": "En inventaire\nActif\nEn diagnostic\nDéfectueux\nRetourné\nPerdu",
|
||||||
"default": "En inventaire", "in_list_view": 1},
|
"default": "En inventaire", "in_list_view": 1},
|
||||||
{"fieldname": "ownership", "fieldtype": "Select", "label": "Propriété",
|
{"fieldname": "ownership", "fieldtype": "Select", "label": "Propriété",
|
||||||
"options": "Gigafibre\nClient", "default": "Gigafibre"},
|
"options": "Gigafibre\nClient", "default": "Gigafibre"},
|
||||||
|
|
|
||||||
396
scripts/migration/add_missing_custom_fields.py
Normal file
396
scripts/migration/add_missing_custom_fields.py
Normal file
|
|
@ -0,0 +1,396 @@
|
||||||
|
"""
|
||||||
|
add_missing_custom_fields.py — Add all missing custom fields to ERPNext doctypes.
|
||||||
|
|
||||||
|
Covers the complete field gap identified in LEGACY-FIELD-GAP-ANALYSIS.md.
|
||||||
|
Adds ~73 custom fields across Customer, Service Subscription, Service Equipment,
|
||||||
|
Service Location, and Dispatch Job.
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/add_missing_custom_fields.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
|
||||||
|
def add_custom_fields(doctype, fields):
|
||||||
|
"""Add a list of custom fields to a doctype, skipping existing ones."""
|
||||||
|
count = 0
|
||||||
|
for f in fields:
|
||||||
|
fieldname = f["fieldname"]
|
||||||
|
exists = frappe.db.exists("Custom Field", {"dt": doctype, "fieldname": fieldname})
|
||||||
|
if exists:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
doc = frappe.get_doc({
|
||||||
|
"doctype": "Custom Field",
|
||||||
|
"dt": doctype,
|
||||||
|
**f,
|
||||||
|
})
|
||||||
|
doc.insert(ignore_permissions=True)
|
||||||
|
count += 1
|
||||||
|
print(" + {}.{}".format(doctype, fieldname))
|
||||||
|
except Exception as e:
|
||||||
|
print(" ! {}.{} — ERROR: {}".format(doctype, fieldname, str(e)[:100]))
|
||||||
|
frappe.db.commit()
|
||||||
|
return count
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# 1. CUSTOMER — PPA, Stripe, Termination, VIP, Portal, Marketing
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("CUSTOMER — Adding missing fields")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
customer_fields = [
|
||||||
|
# ── PPA (Pre-Authorized Payment) Section ──
|
||||||
|
{"fieldname": "ppa_section", "label": "Paiement pré-autorisé (PPA)", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "notes_internal", "collapsible": 1},
|
||||||
|
{"fieldname": "ppa_enabled", "label": "PPA activé", "fieldtype": "Check",
|
||||||
|
"insert_after": "ppa_section"},
|
||||||
|
{"fieldname": "ppa_name", "label": "Nom du payeur", "fieldtype": "Data",
|
||||||
|
"insert_after": "ppa_enabled", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
{"fieldname": "ppa_code", "label": "Transit bancaire", "fieldtype": "Data",
|
||||||
|
"insert_after": "ppa_name", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
{"fieldname": "ppa_branch", "label": "Succursale", "fieldtype": "Data",
|
||||||
|
"insert_after": "ppa_code", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
{"fieldname": "ppa_col_break", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "ppa_branch"},
|
||||||
|
{"fieldname": "ppa_account", "label": "Numéro de compte", "fieldtype": "Data",
|
||||||
|
"insert_after": "ppa_col_break", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
{"fieldname": "ppa_amount", "label": "Montant PPA", "fieldtype": "Currency",
|
||||||
|
"insert_after": "ppa_account", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
{"fieldname": "ppa_amount_buffer", "label": "Montant tampon", "fieldtype": "Currency",
|
||||||
|
"insert_after": "ppa_amount", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
{"fieldname": "ppa_fixed", "label": "Montant fixe", "fieldtype": "Check",
|
||||||
|
"insert_after": "ppa_amount_buffer", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
{"fieldname": "ppa_cc", "label": "PPA par carte de crédit", "fieldtype": "Check",
|
||||||
|
"insert_after": "ppa_fixed", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
{"fieldname": "ppa_all_invoice", "label": "Appliquer à toutes les factures", "fieldtype": "Check",
|
||||||
|
"insert_after": "ppa_cc", "depends_on": "eval:doc.ppa_enabled"},
|
||||||
|
|
||||||
|
# ── Stripe Section ──
|
||||||
|
{"fieldname": "stripe_section", "label": "Stripe", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "ppa_all_invoice", "collapsible": 1},
|
||||||
|
{"fieldname": "stripe_customer_id", "label": "Stripe Customer ID", "fieldtype": "Data",
|
||||||
|
"insert_after": "stripe_section", "description": "cus_XXXXX"},
|
||||||
|
{"fieldname": "stripe_ppa_enabled", "label": "Paiement auto Stripe", "fieldtype": "Check",
|
||||||
|
"insert_after": "stripe_customer_id"},
|
||||||
|
|
||||||
|
# ── Flags ──
|
||||||
|
{"fieldname": "is_vip", "label": "Client VIP", "fieldtype": "Check",
|
||||||
|
"insert_after": "is_bad_payer"},
|
||||||
|
{"fieldname": "is_land_owner", "label": "Propriétaire terrain", "fieldtype": "Check",
|
||||||
|
"insert_after": "is_vip"},
|
||||||
|
{"fieldname": "marketing_optin", "label": "Accepte marketing", "fieldtype": "Check",
|
||||||
|
"insert_after": "is_land_owner"},
|
||||||
|
{"fieldname": "call_contact", "label": "Contact téléphonique", "fieldtype": "Check",
|
||||||
|
"insert_after": "marketing_optin"},
|
||||||
|
|
||||||
|
# ── Termination Section ──
|
||||||
|
{"fieldname": "termination_section", "label": "Résiliation", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "stripe_ppa_enabled", "collapsible": 1},
|
||||||
|
{"fieldname": "terminate_date", "label": "Date résiliation", "fieldtype": "Date",
|
||||||
|
"insert_after": "termination_section"},
|
||||||
|
{"fieldname": "terminate_reason", "label": "Raison", "fieldtype": "Small Text",
|
||||||
|
"insert_after": "terminate_date"},
|
||||||
|
{"fieldname": "terminate_col", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "terminate_reason"},
|
||||||
|
{"fieldname": "terminate_cie", "label": "Compétiteur", "fieldtype": "Data",
|
||||||
|
"insert_after": "terminate_col"},
|
||||||
|
{"fieldname": "terminate_note", "label": "Notes résiliation", "fieldtype": "Small Text",
|
||||||
|
"insert_after": "terminate_cie"},
|
||||||
|
|
||||||
|
# ── Client-visible notes ──
|
||||||
|
{"fieldname": "notes_client", "label": "Notes client (visible)", "fieldtype": "Small Text",
|
||||||
|
"insert_after": "notes_internal", "description": "Notes visibles par le client"},
|
||||||
|
|
||||||
|
# ── Portal ──
|
||||||
|
{"fieldname": "portal_section", "label": "Portail client (legacy)", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "terminate_note", "collapsible": 1},
|
||||||
|
{"fieldname": "portal_username", "label": "Nom d'utilisateur", "fieldtype": "Data",
|
||||||
|
"insert_after": "portal_section"},
|
||||||
|
{"fieldname": "portal_password_hash", "label": "Hash mot de passe (MD5)", "fieldtype": "Data",
|
||||||
|
"insert_after": "portal_username", "read_only": 1},
|
||||||
|
|
||||||
|
# ── Misc ──
|
||||||
|
{"fieldname": "middle_name", "label": "Deuxième prénom", "fieldtype": "Data",
|
||||||
|
"insert_after": "customer_name"},
|
||||||
|
{"fieldname": "salutation_legacy", "label": "Titre (legacy)", "fieldtype": "Data",
|
||||||
|
"insert_after": "middle_name"},
|
||||||
|
{"fieldname": "search_keyword", "label": "Mot-clé recherche", "fieldtype": "Data",
|
||||||
|
"insert_after": "portal_password_hash"},
|
||||||
|
]
|
||||||
|
|
||||||
|
add_custom_fields("Customer", customer_fields)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# 2. SERVICE SUBSCRIPTION — Custom pricing, dates, quotas, IP
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SERVICE SUBSCRIPTION — Adding missing fields")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
subscription_fields = [
|
||||||
|
# ── Custom pricing ──
|
||||||
|
{"fieldname": "custom_pricing_section", "label": "Tarification personnalisée", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "notes", "collapsible": 1},
|
||||||
|
{"fieldname": "is_custom_pricing", "label": "Prix personnalisé (hijack)", "fieldtype": "Check",
|
||||||
|
"insert_after": "custom_pricing_section"},
|
||||||
|
{"fieldname": "custom_pricing_desc", "label": "Description prix custom", "fieldtype": "Data",
|
||||||
|
"insert_after": "is_custom_pricing", "depends_on": "eval:doc.is_custom_pricing"},
|
||||||
|
{"fieldname": "custom_col", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "custom_pricing_desc"},
|
||||||
|
{"fieldname": "quota_day_gb", "label": "Quota jour (Go)", "fieldtype": "Float",
|
||||||
|
"insert_after": "custom_col"},
|
||||||
|
{"fieldname": "quota_night_gb", "label": "Quota nuit (Go)", "fieldtype": "Float",
|
||||||
|
"insert_after": "quota_day_gb"},
|
||||||
|
|
||||||
|
# ── Extended dates ──
|
||||||
|
{"fieldname": "dates_extra_section", "label": "Dates supplémentaires", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "quota_night_gb", "collapsible": 1},
|
||||||
|
{"fieldname": "date_suspended", "label": "Date suspension", "fieldtype": "Date",
|
||||||
|
"insert_after": "dates_extra_section"},
|
||||||
|
{"fieldname": "active_until", "label": "Actif jusqu'au", "fieldtype": "Date",
|
||||||
|
"insert_after": "date_suspended"},
|
||||||
|
{"fieldname": "dates_extra_col", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "active_until"},
|
||||||
|
{"fieldname": "next_invoice_date", "label": "Prochaine facturation", "fieldtype": "Date",
|
||||||
|
"insert_after": "dates_extra_col"},
|
||||||
|
|
||||||
|
# ── Network ──
|
||||||
|
{"fieldname": "network_section", "label": "Réseau", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "next_invoice_date", "collapsible": 1},
|
||||||
|
{"fieldname": "static_ip", "label": "IP fixe", "fieldtype": "Data",
|
||||||
|
"insert_after": "network_section"},
|
||||||
|
{"fieldname": "forfait_internet", "label": "Forfait internet", "fieldtype": "Check",
|
||||||
|
"insert_after": "static_ip"},
|
||||||
|
{"fieldname": "radius_consumption", "label": "Consommation RADIUS", "fieldtype": "Data",
|
||||||
|
"insert_after": "forfait_internet"},
|
||||||
|
]
|
||||||
|
|
||||||
|
add_custom_fields("Service Subscription", subscription_fields)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# 3. SERVICE EQUIPMENT — Management, Provisioning, ACS, IPTV
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SERVICE EQUIPMENT — Adding missing fields")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Note: Service Equipment is a custom doctype, so we add fields directly
|
||||||
|
# to the DocType definition (not as Custom Field). We use ALTER TABLE.
|
||||||
|
# But if it was created via setup_fsm_doctypes.py, we can still use Custom Field pattern.
|
||||||
|
|
||||||
|
equipment_fields = [
|
||||||
|
# ── Management Access ──
|
||||||
|
{"fieldname": "mgmt_section", "label": "Accès de gestion", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "login_password", "collapsible": 1},
|
||||||
|
{"fieldname": "manage_url", "label": "URL de gestion", "fieldtype": "Data",
|
||||||
|
"insert_after": "mgmt_section", "description": "http://172.17.x.x:8080 ou IP simple"},
|
||||||
|
{"fieldname": "manage_port", "label": "Port gestion", "fieldtype": "Int",
|
||||||
|
"insert_after": "manage_url"},
|
||||||
|
{"fieldname": "manage_protocol", "label": "Protocole gestion", "fieldtype": "Select",
|
||||||
|
"options": "\nHTTP\nHTTPS\nSSH\nTelnet\nSNMP", "insert_after": "manage_port"},
|
||||||
|
{"fieldname": "mgmt_col", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "manage_protocol"},
|
||||||
|
{"fieldname": "cli_ip", "label": "IP CLI", "fieldtype": "Data",
|
||||||
|
"insert_after": "mgmt_col"},
|
||||||
|
{"fieldname": "cli_port", "label": "Port CLI", "fieldtype": "Int",
|
||||||
|
"insert_after": "cli_ip"},
|
||||||
|
{"fieldname": "cli_protocol", "label": "Protocole CLI", "fieldtype": "Select",
|
||||||
|
"options": "\nSSH\nTelnet", "insert_after": "cli_port"},
|
||||||
|
|
||||||
|
# ── Device hierarchy ──
|
||||||
|
{"fieldname": "parent_device", "label": "Équipement parent", "fieldtype": "Link",
|
||||||
|
"options": "Service Equipment", "insert_after": "cli_protocol",
|
||||||
|
"description": "ONT parent pour routeurs/décodeurs, ou OLT pour ONTs"},
|
||||||
|
{"fieldname": "legacy_category", "label": "Catégorie legacy", "fieldtype": "Data",
|
||||||
|
"insert_after": "parent_device", "read_only": 1},
|
||||||
|
{"fieldname": "device_name_legacy", "label": "Nom legacy", "fieldtype": "Data",
|
||||||
|
"insert_after": "legacy_category", "read_only": 1},
|
||||||
|
|
||||||
|
# ── WiFi Provisioning ──
|
||||||
|
{"fieldname": "wifi_section", "label": "WiFi (provisionnement)", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "device_name_legacy", "collapsible": 1},
|
||||||
|
{"fieldname": "wifi_ssid", "label": "SSID WiFi", "fieldtype": "Data",
|
||||||
|
"insert_after": "wifi_section"},
|
||||||
|
{"fieldname": "wifi_password", "label": "Mot de passe WiFi", "fieldtype": "Password",
|
||||||
|
"insert_after": "wifi_ssid"},
|
||||||
|
{"fieldname": "wifi_col", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "wifi_password"},
|
||||||
|
{"fieldname": "wifi_ssid_5g", "label": "SSID WiFi 5GHz", "fieldtype": "Data",
|
||||||
|
"insert_after": "wifi_col", "description": "Si différent du 2.4GHz"},
|
||||||
|
{"fieldname": "wifi_enabled", "label": "WiFi activé", "fieldtype": "Check",
|
||||||
|
"insert_after": "wifi_ssid_5g", "default": "1"},
|
||||||
|
|
||||||
|
# ── VoIP Provisioning ──
|
||||||
|
{"fieldname": "voip_section", "label": "VoIP (provisionnement)", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "wifi_enabled", "collapsible": 1},
|
||||||
|
{"fieldname": "sip_username", "label": "SIP Username", "fieldtype": "Data",
|
||||||
|
"insert_after": "voip_section"},
|
||||||
|
{"fieldname": "sip_password", "label": "SIP Password", "fieldtype": "Password",
|
||||||
|
"insert_after": "sip_username"},
|
||||||
|
{"fieldname": "voip_col", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "sip_password"},
|
||||||
|
{"fieldname": "sip_line", "label": "Ligne SIP", "fieldtype": "Int",
|
||||||
|
"insert_after": "voip_col", "default": "1"},
|
||||||
|
|
||||||
|
# ── GPON / ACS ──
|
||||||
|
{"fieldname": "gpon_section", "label": "GPON / ACS", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "sip_line", "collapsible": 1},
|
||||||
|
{"fieldname": "gpon_serial", "label": "GPON Serial (physique)", "fieldtype": "Data",
|
||||||
|
"insert_after": "gpon_section", "description": "RCMG/TPLG sur l'étiquette"},
|
||||||
|
{"fieldname": "cwmp_serial", "label": "CWMP Serial (GenieACS)", "fieldtype": "Data",
|
||||||
|
"insert_after": "gpon_serial", "description": "Serial interne TR-069"},
|
||||||
|
{"fieldname": "acs_device_id", "label": "ACS Device ID", "fieldtype": "Data",
|
||||||
|
"insert_after": "cwmp_serial", "description": "ID complet GenieACS/Oktopus"},
|
||||||
|
{"fieldname": "gpon_col", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "acs_device_id"},
|
||||||
|
{"fieldname": "fibre_line_profile", "label": "Line Profile ID", "fieldtype": "Data",
|
||||||
|
"insert_after": "gpon_col"},
|
||||||
|
{"fieldname": "fibre_service_profile", "label": "Service Profile ID", "fieldtype": "Data",
|
||||||
|
"insert_after": "fibre_line_profile"},
|
||||||
|
{"fieldname": "acs_last_inform", "label": "Dernier inform ACS", "fieldtype": "Datetime",
|
||||||
|
"insert_after": "fibre_service_profile", "read_only": 1},
|
||||||
|
{"fieldname": "acs_online", "label": "En ligne (ACS)", "fieldtype": "Check",
|
||||||
|
"insert_after": "acs_last_inform", "read_only": 1},
|
||||||
|
{"fieldname": "wan_ip", "label": "IP WAN", "fieldtype": "Data",
|
||||||
|
"insert_after": "acs_online", "read_only": 1},
|
||||||
|
|
||||||
|
# ── IPTV ──
|
||||||
|
{"fieldname": "iptv_section", "label": "IPTV", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "wan_ip", "collapsible": 1},
|
||||||
|
{"fieldname": "iptv_subscription_id", "label": "Ministra/IPTV Sub ID", "fieldtype": "Data",
|
||||||
|
"insert_after": "iptv_section"},
|
||||||
|
{"fieldname": "mac_addresses_json", "label": "MACs par interface (JSON)", "fieldtype": "Long Text",
|
||||||
|
"insert_after": "iptv_subscription_id", "read_only": 1,
|
||||||
|
"description": '{"eth0":"AA:BB:CC:DD:EE:FF","eth1":"..."}'},
|
||||||
|
]
|
||||||
|
|
||||||
|
# For Service Equipment (custom doctype), we need to check if these should be
|
||||||
|
# Custom Fields or direct DocType field additions.
|
||||||
|
# Since it was created as custom=1, Custom Fields work.
|
||||||
|
add_custom_fields("Service Equipment", equipment_fields)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# 4. SERVICE LOCATION — Fibre infrastructure, VLANs, legacy IDs
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("SERVICE LOCATION — Adding missing fields")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
location_fields = [
|
||||||
|
# ── OLT / Fibre detail ──
|
||||||
|
{"fieldname": "fibre_detail_section", "label": "Détails fibre", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "network_notes", "collapsible": 1},
|
||||||
|
{"fieldname": "legacy_fibre_id", "label": "Fibre ID (legacy)", "fieldtype": "Int",
|
||||||
|
"insert_after": "fibre_detail_section"},
|
||||||
|
{"fieldname": "ont_serial", "label": "ONT Serial", "fieldtype": "Data",
|
||||||
|
"insert_after": "legacy_fibre_id"},
|
||||||
|
{"fieldname": "olt_ip", "label": "OLT IP", "fieldtype": "Data",
|
||||||
|
"insert_after": "ont_serial"},
|
||||||
|
{"fieldname": "olt_name", "label": "Nom OLT", "fieldtype": "Data",
|
||||||
|
"insert_after": "olt_ip"},
|
||||||
|
{"fieldname": "fibre_col1", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "olt_name"},
|
||||||
|
{"fieldname": "ont_id", "label": "ONT ID", "fieldtype": "Int",
|
||||||
|
"insert_after": "fibre_col1"},
|
||||||
|
{"fieldname": "terrain_type", "label": "Type terrain", "fieldtype": "Data",
|
||||||
|
"insert_after": "ont_id"},
|
||||||
|
{"fieldname": "fibre_distance_m", "label": "Distance fibre (m)", "fieldtype": "Float",
|
||||||
|
"insert_after": "terrain_type"},
|
||||||
|
{"fieldname": "fibre_spans", "label": "Nombre de portées", "fieldtype": "Int",
|
||||||
|
"insert_after": "fibre_distance_m"},
|
||||||
|
{"fieldname": "install_time_estimate", "label": "Temps d'installation estimé", "fieldtype": "Data",
|
||||||
|
"insert_after": "fibre_spans"},
|
||||||
|
|
||||||
|
# ── VLANs (individual, replacing concatenated network_id) ──
|
||||||
|
{"fieldname": "vlan_section", "label": "VLANs", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "install_time_estimate", "collapsible": 1},
|
||||||
|
{"fieldname": "vlan_manage", "label": "VLAN Gestion", "fieldtype": "Int",
|
||||||
|
"insert_after": "vlan_section"},
|
||||||
|
{"fieldname": "vlan_internet", "label": "VLAN Internet", "fieldtype": "Int",
|
||||||
|
"insert_after": "vlan_manage"},
|
||||||
|
{"fieldname": "vlan_col", "fieldtype": "Column Break",
|
||||||
|
"insert_after": "vlan_internet"},
|
||||||
|
{"fieldname": "vlan_telephone", "label": "VLAN Téléphone", "fieldtype": "Int",
|
||||||
|
"insert_after": "vlan_col"},
|
||||||
|
{"fieldname": "vlan_tv", "label": "VLAN Télévision", "fieldtype": "Int",
|
||||||
|
"insert_after": "vlan_telephone"},
|
||||||
|
|
||||||
|
# ── Building / Apartment ──
|
||||||
|
{"fieldname": "building_section", "label": "Immeuble", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "vlan_tv", "collapsible": 1},
|
||||||
|
{"fieldname": "address_line_2", "label": "Adresse ligne 2", "fieldtype": "Data",
|
||||||
|
"insert_after": "building_section"},
|
||||||
|
{"fieldname": "apartment_number", "label": "Numéro d'appartement", "fieldtype": "Data",
|
||||||
|
"insert_after": "address_line_2"},
|
||||||
|
{"fieldname": "is_apartment", "label": "Est un appartement", "fieldtype": "Check",
|
||||||
|
"insert_after": "apartment_number"},
|
||||||
|
{"fieldname": "apartment_building_id", "label": "ID immeuble", "fieldtype": "Data",
|
||||||
|
"insert_after": "is_apartment"},
|
||||||
|
{"fieldname": "box_not_installed", "label": "Boîtier pas installé", "fieldtype": "Check",
|
||||||
|
"insert_after": "apartment_building_id"},
|
||||||
|
|
||||||
|
# ── Legacy service IDs (for cross-reference) ──
|
||||||
|
{"fieldname": "legacy_ids_section", "label": "IDs legacy (référence)", "fieldtype": "Section Break",
|
||||||
|
"insert_after": "box_not_installed", "collapsible": 1, "hidden": 1},
|
||||||
|
{"fieldname": "manage_service_id", "label": "Service gestion ID", "fieldtype": "Int",
|
||||||
|
"insert_after": "legacy_ids_section"},
|
||||||
|
{"fieldname": "internet_service_id", "label": "Service internet ID", "fieldtype": "Int",
|
||||||
|
"insert_after": "manage_service_id"},
|
||||||
|
{"fieldname": "telephone_service_id", "label": "Service téléphone ID", "fieldtype": "Int",
|
||||||
|
"insert_after": "internet_service_id"},
|
||||||
|
{"fieldname": "tv_service_id", "label": "Service TV ID", "fieldtype": "Int",
|
||||||
|
"insert_after": "telephone_service_id"},
|
||||||
|
{"fieldname": "placemarks_id", "label": "Placemarks ID", "fieldtype": "Data",
|
||||||
|
"insert_after": "tv_service_id"},
|
||||||
|
{"fieldname": "delivery_notes", "label": "Notes adresse", "fieldtype": "Small Text",
|
||||||
|
"insert_after": "access_notes"},
|
||||||
|
]
|
||||||
|
|
||||||
|
add_custom_fields("Service Location", location_fields)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# 5. DISPATCH JOB — Second tech + time tracking
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("DISPATCH JOB — Adding missing fields")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
dispatch_fields = [
|
||||||
|
{"fieldname": "second_technician", "label": "Deuxième technicien", "fieldtype": "Data",
|
||||||
|
"insert_after": "actual_end"},
|
||||||
|
{"fieldname": "tech1_arrival", "label": "Arrivée tech 1", "fieldtype": "Datetime",
|
||||||
|
"insert_after": "second_technician"},
|
||||||
|
{"fieldname": "tech1_departure", "label": "Départ tech 1", "fieldtype": "Datetime",
|
||||||
|
"insert_after": "tech1_arrival"},
|
||||||
|
{"fieldname": "tech2_arrival", "label": "Arrivée tech 2", "fieldtype": "Datetime",
|
||||||
|
"insert_after": "tech1_departure"},
|
||||||
|
{"fieldname": "tech2_departure", "label": "Départ tech 2", "fieldtype": "Datetime",
|
||||||
|
"insert_after": "tech2_arrival"},
|
||||||
|
]
|
||||||
|
|
||||||
|
add_custom_fields("Dispatch Job", dispatch_fields)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
# DONE
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("ALL CUSTOM FIELDS ADDED")
|
||||||
|
print("=" * 60)
|
||||||
|
print("Next steps:")
|
||||||
|
print(" 1. Run migrate_missing_data.py to populate the new fields")
|
||||||
|
print(" 2. Run migrate_provisioning_data.py for WiFi/VoIP from GenieACS")
|
||||||
515
scripts/migration/migrate_missing_data.py
Normal file
515
scripts/migration/migrate_missing_data.py
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
"""
|
||||||
|
migrate_missing_data.py — Populate the new custom fields from legacy DB.
|
||||||
|
|
||||||
|
Prerequisites: add_missing_custom_fields.py must have been run first.
|
||||||
|
|
||||||
|
Migrates:
|
||||||
|
1. Customer: PPA, Stripe, VIP, termination, portal, flags
|
||||||
|
2. Service Subscription: hijack details, dates, quotas
|
||||||
|
3. Service Equipment: management access, parent hierarchy, legacy category
|
||||||
|
4. Service Location: fibre detail (VLANs individual, OLT IP/name, ONT ID, etc.)
|
||||||
|
5. Dispatch Job: tech times from bon_travail
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/migrate_missing_data.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import pymysql
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
conn = pymysql.connect(
|
||||||
|
host="10.100.80.100",
|
||||||
|
user="facturation",
|
||||||
|
password="VD67owoj",
|
||||||
|
database="gestionclient",
|
||||||
|
cursorclass=pymysql.cursors.DictCursor
|
||||||
|
)
|
||||||
|
|
||||||
|
def ts_to_date(ts):
|
||||||
|
"""Convert unix timestamp to YYYY-MM-DD, return None on failure."""
|
||||||
|
if not ts or ts == 0:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(int(ts)).strftime("%Y-%m-%d")
|
||||||
|
except (ValueError, OSError, OverflowError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# BUILD MAPS
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("Building lookup maps...")
|
||||||
|
|
||||||
|
# legacy_account_id → ERPNext Customer name
|
||||||
|
cust_map = {}
|
||||||
|
for r in frappe.db.sql('SELECT name, legacy_account_id FROM "tabCustomer" WHERE legacy_account_id IS NOT NULL AND legacy_account_id > 0', as_dict=True):
|
||||||
|
cust_map[r["legacy_account_id"]] = r["name"]
|
||||||
|
print(" Customer map: {}".format(len(cust_map)))
|
||||||
|
|
||||||
|
# legacy_delivery_id → ERPNext Service Location name
|
||||||
|
loc_map = {}
|
||||||
|
for r in frappe.db.sql('SELECT name, legacy_delivery_id FROM "tabService Location" WHERE legacy_delivery_id IS NOT NULL AND legacy_delivery_id > 0', as_dict=True):
|
||||||
|
loc_map[r["legacy_delivery_id"]] = r["name"]
|
||||||
|
print(" Location map: {}".format(len(loc_map)))
|
||||||
|
|
||||||
|
# legacy_service_id → ERPNext Service Subscription name
|
||||||
|
sub_map = {}
|
||||||
|
for r in frappe.db.sql('SELECT name, legacy_service_id FROM "tabService Subscription" WHERE legacy_service_id IS NOT NULL AND legacy_service_id > 0', as_dict=True):
|
||||||
|
sub_map[r["legacy_service_id"]] = r["name"]
|
||||||
|
print(" Subscription map: {}".format(len(sub_map)))
|
||||||
|
|
||||||
|
# legacy_device_id → ERPNext Service Equipment name
|
||||||
|
dev_map = {}
|
||||||
|
for r in frappe.db.sql('SELECT name, legacy_device_id FROM "tabService Equipment" WHERE legacy_device_id IS NOT NULL AND legacy_device_id > 0', as_dict=True):
|
||||||
|
dev_map[r["legacy_device_id"]] = r["name"]
|
||||||
|
print(" Device map: {}".format(len(dev_map)))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 1. CUSTOMER — PPA, Stripe, VIP, termination, portal, flags
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("1. CUSTOMER — PPA, Stripe, VIP, termination, portal")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, middle_name, title,
|
||||||
|
ppa, ppa_name, ppa_code, ppa_branch, ppa_account,
|
||||||
|
ppa_amount, ppa_amount_buffer, ppa_fixed, ppa_cc, ppa_all_invoice,
|
||||||
|
stripe_id, stripe_ppa,
|
||||||
|
vip, land_owner, pub, `call`,
|
||||||
|
terminate_reason, terminate_cie, terminate_note, terminate_date,
|
||||||
|
notes_client,
|
||||||
|
username, password, keyword
|
||||||
|
FROM account
|
||||||
|
""")
|
||||||
|
accounts = cur.fetchall()
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for acct in accounts:
|
||||||
|
cust_name = cust_map.get(acct["id"])
|
||||||
|
if not cust_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sets = []
|
||||||
|
params = {"name": cust_name}
|
||||||
|
|
||||||
|
# PPA fields
|
||||||
|
if acct["ppa"]:
|
||||||
|
sets.append("ppa_enabled = 1")
|
||||||
|
for f in ["ppa_name", "ppa_code", "ppa_branch", "ppa_account"]:
|
||||||
|
if acct.get(f):
|
||||||
|
sets.append("{} = %({})s".format(f, f))
|
||||||
|
params[f] = str(acct[f]).strip() if acct[f] else None
|
||||||
|
if acct["ppa_amount"]:
|
||||||
|
sets.append("ppa_amount = %(ppa_amount)s")
|
||||||
|
params["ppa_amount"] = float(acct["ppa_amount"])
|
||||||
|
if acct["ppa_amount_buffer"]:
|
||||||
|
sets.append("ppa_amount_buffer = %(ppa_amount_buffer)s")
|
||||||
|
params["ppa_amount_buffer"] = float(acct["ppa_amount_buffer"])
|
||||||
|
if acct["ppa_fixed"]:
|
||||||
|
sets.append("ppa_fixed = 1")
|
||||||
|
if acct["ppa_cc"]:
|
||||||
|
sets.append("ppa_cc = 1")
|
||||||
|
if acct["ppa_all_invoice"]:
|
||||||
|
sets.append("ppa_all_invoice = 1")
|
||||||
|
|
||||||
|
# Stripe
|
||||||
|
if acct["stripe_id"]:
|
||||||
|
sets.append("stripe_customer_id = %(stripe_id)s")
|
||||||
|
params["stripe_id"] = str(acct["stripe_id"]).strip()
|
||||||
|
if acct["stripe_ppa"]:
|
||||||
|
sets.append("stripe_ppa_enabled = 1")
|
||||||
|
|
||||||
|
# Flags
|
||||||
|
if acct["vip"]:
|
||||||
|
sets.append("is_vip = 1")
|
||||||
|
if acct["land_owner"]:
|
||||||
|
sets.append("is_land_owner = 1")
|
||||||
|
if acct["pub"]:
|
||||||
|
sets.append("marketing_optin = 1")
|
||||||
|
if acct.get("call"):
|
||||||
|
sets.append("call_contact = 1")
|
||||||
|
|
||||||
|
# Termination
|
||||||
|
if acct["terminate_reason"]:
|
||||||
|
sets.append("terminate_reason = %(terminate_reason)s")
|
||||||
|
params["terminate_reason"] = str(acct["terminate_reason"])
|
||||||
|
if acct["terminate_cie"]:
|
||||||
|
sets.append("terminate_cie = %(terminate_cie)s")
|
||||||
|
params["terminate_cie"] = str(acct["terminate_cie"])
|
||||||
|
if acct["terminate_note"]:
|
||||||
|
sets.append("terminate_note = %(terminate_note)s")
|
||||||
|
params["terminate_note"] = str(acct["terminate_note"])
|
||||||
|
if acct["terminate_date"]:
|
||||||
|
td = ts_to_date(acct["terminate_date"])
|
||||||
|
if td:
|
||||||
|
sets.append("terminate_date = %(terminate_date)s")
|
||||||
|
params["terminate_date"] = td
|
||||||
|
|
||||||
|
# Client notes
|
||||||
|
if acct["notes_client"]:
|
||||||
|
sets.append("notes_client = %(notes_client)s")
|
||||||
|
params["notes_client"] = str(acct["notes_client"])
|
||||||
|
|
||||||
|
# Portal
|
||||||
|
if acct["username"]:
|
||||||
|
sets.append("portal_username = %(portal_username)s")
|
||||||
|
params["portal_username"] = str(acct["username"])
|
||||||
|
if acct["password"]:
|
||||||
|
sets.append("portal_password_hash = %(portal_password_hash)s")
|
||||||
|
params["portal_password_hash"] = str(acct["password"])
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
if acct["middle_name"]:
|
||||||
|
sets.append("middle_name = %(middle_name)s")
|
||||||
|
params["middle_name"] = str(acct["middle_name"]).strip()
|
||||||
|
if acct["title"]:
|
||||||
|
sets.append("salutation_legacy = %(salutation_legacy)s")
|
||||||
|
params["salutation_legacy"] = str(acct["title"]).strip()
|
||||||
|
if acct["keyword"]:
|
||||||
|
sets.append("search_keyword = %(search_keyword)s")
|
||||||
|
params["search_keyword"] = str(acct["keyword"]).strip()
|
||||||
|
|
||||||
|
if sets:
|
||||||
|
sql = 'UPDATE "tabCustomer" SET {} WHERE name = %(name)s'.format(", ".join(sets))
|
||||||
|
frappe.db.sql(sql, params)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated {} customers".format(updated))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 2. SERVICE SUBSCRIPTION — hijack details, dates, quotas
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("2. SERVICE SUBSCRIPTION — hijack, dates, IP")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, hijack, hijack_desc,
|
||||||
|
hijack_quota_day, hijack_quota_night,
|
||||||
|
date_suspended, actif_until, date_next_invoice,
|
||||||
|
forfait_internet, radius_conso, ip_fixe
|
||||||
|
FROM service
|
||||||
|
""")
|
||||||
|
services = cur.fetchall()
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for svc in services:
|
||||||
|
sub_name = sub_map.get(svc["id"])
|
||||||
|
if not sub_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sets = []
|
||||||
|
params = {"name": sub_name}
|
||||||
|
|
||||||
|
if svc["hijack"]:
|
||||||
|
sets.append("is_custom_pricing = 1")
|
||||||
|
if svc["hijack_desc"]:
|
||||||
|
sets.append("custom_pricing_desc = %(hijack_desc)s")
|
||||||
|
params["hijack_desc"] = str(svc["hijack_desc"])
|
||||||
|
if svc["hijack_quota_day"]:
|
||||||
|
sets.append("quota_day_gb = %(quota_day)s")
|
||||||
|
params["quota_day"] = float(svc["hijack_quota_day"])
|
||||||
|
if svc["hijack_quota_night"]:
|
||||||
|
sets.append("quota_night_gb = %(quota_night)s")
|
||||||
|
params["quota_night"] = float(svc["hijack_quota_night"])
|
||||||
|
|
||||||
|
ds = ts_to_date(svc["date_suspended"])
|
||||||
|
if ds:
|
||||||
|
sets.append("date_suspended = %(date_suspended)s")
|
||||||
|
params["date_suspended"] = ds
|
||||||
|
au = ts_to_date(svc["actif_until"])
|
||||||
|
if au:
|
||||||
|
sets.append("active_until = %(active_until)s")
|
||||||
|
params["active_until"] = au
|
||||||
|
ni = ts_to_date(svc["date_next_invoice"])
|
||||||
|
if ni:
|
||||||
|
sets.append("next_invoice_date = %(next_invoice_date)s")
|
||||||
|
params["next_invoice_date"] = ni
|
||||||
|
|
||||||
|
if svc["forfait_internet"]:
|
||||||
|
sets.append("forfait_internet = 1")
|
||||||
|
if svc["radius_conso"]:
|
||||||
|
sets.append("radius_consumption = %(radius_conso)s")
|
||||||
|
params["radius_conso"] = str(svc["radius_conso"])
|
||||||
|
if svc.get("ip_fixe"):
|
||||||
|
sets.append("static_ip = %(static_ip)s")
|
||||||
|
params["static_ip"] = str(svc["ip_fixe"])
|
||||||
|
|
||||||
|
if sets:
|
||||||
|
sql = 'UPDATE "tabService Subscription" SET {} WHERE name = %(name)s'.format(", ".join(sets))
|
||||||
|
frappe.db.sql(sql, params)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated {} subscriptions".format(updated))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 3. SERVICE EQUIPMENT — management, parent, category, device_attr
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("3. SERVICE EQUIPMENT — management, parent, category")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT id, manage, port, protocol, manage_cli, port_cli, protocol_cli,
|
||||||
|
parent, category, name as device_name
|
||||||
|
FROM device
|
||||||
|
""")
|
||||||
|
devices = cur.fetchall()
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for dev in devices:
|
||||||
|
eq_name = dev_map.get(dev["id"])
|
||||||
|
if not eq_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sets = []
|
||||||
|
params = {"name": eq_name}
|
||||||
|
|
||||||
|
if dev["manage"]:
|
||||||
|
sets.append("manage_url = %(manage_url)s")
|
||||||
|
params["manage_url"] = str(dev["manage"]).strip()
|
||||||
|
if dev["port"]:
|
||||||
|
sets.append("manage_port = %(manage_port)s")
|
||||||
|
params["manage_port"] = int(dev["port"])
|
||||||
|
if dev["protocol"]:
|
||||||
|
proto = str(dev["protocol"]).strip().upper()
|
||||||
|
if proto in ("HTTP", "HTTPS", "SSH", "TELNET", "SNMP"):
|
||||||
|
sets.append("manage_protocol = %(manage_protocol)s")
|
||||||
|
params["manage_protocol"] = proto
|
||||||
|
if dev["manage_cli"]:
|
||||||
|
sets.append("cli_ip = %(cli_ip)s")
|
||||||
|
params["cli_ip"] = str(dev["manage_cli"]).strip()
|
||||||
|
if dev["port_cli"]:
|
||||||
|
sets.append("cli_port = %(cli_port)s")
|
||||||
|
params["cli_port"] = int(dev["port_cli"])
|
||||||
|
if dev["protocol_cli"]:
|
||||||
|
proto_cli = str(dev["protocol_cli"]).strip().upper()
|
||||||
|
if proto_cli in ("SSH", "TELNET"):
|
||||||
|
sets.append("cli_protocol = %(cli_protocol)s")
|
||||||
|
params["cli_protocol"] = proto_cli
|
||||||
|
if dev["parent"]:
|
||||||
|
parent_eq = dev_map.get(dev["parent"])
|
||||||
|
if parent_eq:
|
||||||
|
sets.append("parent_device = %(parent_device)s")
|
||||||
|
params["parent_device"] = parent_eq
|
||||||
|
if dev["category"]:
|
||||||
|
sets.append("legacy_category = %(legacy_category)s")
|
||||||
|
params["legacy_category"] = str(dev["category"])
|
||||||
|
if dev["device_name"]:
|
||||||
|
sets.append("device_name_legacy = %(device_name_legacy)s")
|
||||||
|
params["device_name_legacy"] = str(dev["device_name"])
|
||||||
|
|
||||||
|
if sets:
|
||||||
|
sql = 'UPDATE "tabService Equipment" SET {} WHERE name = %(name)s'.format(", ".join(sets))
|
||||||
|
frappe.db.sql(sql, params)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated {} devices".format(updated))
|
||||||
|
|
||||||
|
# ── device_attr → JSON MAC addresses + stb_id ──
|
||||||
|
print("\nImporting device_attr...")
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT device_id, `key`, value FROM device_attr ORDER BY device_id")
|
||||||
|
attrs = cur.fetchall()
|
||||||
|
|
||||||
|
# Group by device_id
|
||||||
|
device_attrs = {}
|
||||||
|
for attr in attrs:
|
||||||
|
did = attr["device_id"]
|
||||||
|
if did not in device_attrs:
|
||||||
|
device_attrs[did] = {}
|
||||||
|
device_attrs[did][attr["key"]] = attr["value"]
|
||||||
|
|
||||||
|
updated_attrs = 0
|
||||||
|
for did, kv in device_attrs.items():
|
||||||
|
eq_name = dev_map.get(did)
|
||||||
|
if not eq_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sets = []
|
||||||
|
params = {"name": eq_name}
|
||||||
|
|
||||||
|
# Extract MAC addresses
|
||||||
|
macs = {}
|
||||||
|
stb_id = None
|
||||||
|
for k, v in kv.items():
|
||||||
|
if k.startswith("mac_") or k.startswith("eth"):
|
||||||
|
macs[k] = v
|
||||||
|
if k == "stb_id":
|
||||||
|
stb_id = v
|
||||||
|
|
||||||
|
if macs:
|
||||||
|
sets.append("mac_addresses_json = %(macs_json)s")
|
||||||
|
params["macs_json"] = json.dumps(macs)
|
||||||
|
if stb_id:
|
||||||
|
sets.append("iptv_subscription_id = %(stb_id)s")
|
||||||
|
params["stb_id"] = str(stb_id)
|
||||||
|
|
||||||
|
if sets:
|
||||||
|
sql = 'UPDATE "tabService Equipment" SET {} WHERE name = %(name)s'.format(", ".join(sets))
|
||||||
|
frappe.db.sql(sql, params)
|
||||||
|
updated_attrs += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated {} devices with attrs".format(updated_attrs))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 4. SERVICE LOCATION — fibre detail (VLANs, OLT IP, ONT, etc.)
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("4. SERVICE LOCATION — fibre detail, VLANs, building")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("""
|
||||||
|
SELECT f.id as fibre_id, f.service_id, f.sn as ont_sn,
|
||||||
|
f.info_connect as olt_ip, f.ontid, f.tech as terrain,
|
||||||
|
f.distance, f.nb_portees, f.temps_estim, f.suite,
|
||||||
|
f.boitier_pas_install,
|
||||||
|
f.vlan_manage, f.vlan_internet, f.vlan_telephone, f.vlan_tele,
|
||||||
|
f.manage_service_id, f.internet_service_id,
|
||||||
|
f.telephone_service_id, f.tele_service_id,
|
||||||
|
f.placemarks_id, f.appartements_id,
|
||||||
|
fo.name as olt_name,
|
||||||
|
s.delivery_id
|
||||||
|
FROM fibre f
|
||||||
|
LEFT JOIN fibre_olt fo ON f.olt_id = fo.id
|
||||||
|
LEFT JOIN service s ON f.service_id = s.id
|
||||||
|
WHERE s.delivery_id IS NOT NULL
|
||||||
|
""")
|
||||||
|
fibres = cur.fetchall()
|
||||||
|
|
||||||
|
updated = 0
|
||||||
|
for fb in fibres:
|
||||||
|
loc_name = loc_map.get(fb["delivery_id"])
|
||||||
|
if not loc_name:
|
||||||
|
continue
|
||||||
|
|
||||||
|
sets = []
|
||||||
|
params = {"name": loc_name}
|
||||||
|
|
||||||
|
if fb["fibre_id"]:
|
||||||
|
sets.append("legacy_fibre_id = %(fibre_id)s")
|
||||||
|
params["fibre_id"] = fb["fibre_id"]
|
||||||
|
if fb["ont_sn"]:
|
||||||
|
sets.append("ont_serial = %(ont_sn)s")
|
||||||
|
params["ont_sn"] = str(fb["ont_sn"])
|
||||||
|
if fb["olt_ip"]:
|
||||||
|
sets.append("olt_ip = %(olt_ip)s")
|
||||||
|
params["olt_ip"] = str(fb["olt_ip"])
|
||||||
|
if fb["olt_name"]:
|
||||||
|
sets.append("olt_name = %(olt_name)s")
|
||||||
|
params["olt_name"] = str(fb["olt_name"])
|
||||||
|
if fb["ontid"]:
|
||||||
|
sets.append("ont_id = %(ontid)s")
|
||||||
|
params["ontid"] = int(fb["ontid"])
|
||||||
|
if fb["terrain"]:
|
||||||
|
sets.append("terrain_type = %(terrain)s")
|
||||||
|
params["terrain"] = str(fb["terrain"])
|
||||||
|
if fb["distance"]:
|
||||||
|
sets.append("fibre_distance_m = %(distance)s")
|
||||||
|
params["distance"] = float(fb["distance"])
|
||||||
|
if fb["nb_portees"]:
|
||||||
|
sets.append("fibre_spans = %(spans)s")
|
||||||
|
params["spans"] = int(fb["nb_portees"])
|
||||||
|
if fb["temps_estim"]:
|
||||||
|
sets.append("install_time_estimate = %(temps)s")
|
||||||
|
params["temps"] = str(fb["temps_estim"])
|
||||||
|
if fb["suite"]:
|
||||||
|
sets.append("is_apartment = 1")
|
||||||
|
if fb["boitier_pas_install"]:
|
||||||
|
sets.append("box_not_installed = 1")
|
||||||
|
|
||||||
|
# VLANs — individual columns
|
||||||
|
if fb["vlan_manage"]:
|
||||||
|
sets.append("vlan_manage = %(vlan_manage)s")
|
||||||
|
params["vlan_manage"] = int(fb["vlan_manage"])
|
||||||
|
if fb["vlan_internet"]:
|
||||||
|
sets.append("vlan_internet = %(vlan_internet)s")
|
||||||
|
params["vlan_internet"] = int(fb["vlan_internet"])
|
||||||
|
if fb["vlan_telephone"]:
|
||||||
|
sets.append("vlan_telephone = %(vlan_telephone)s")
|
||||||
|
params["vlan_telephone"] = int(fb["vlan_telephone"])
|
||||||
|
if fb["vlan_tele"]:
|
||||||
|
sets.append("vlan_tv = %(vlan_tv)s")
|
||||||
|
params["vlan_tv"] = int(fb["vlan_tele"])
|
||||||
|
|
||||||
|
# Legacy service IDs
|
||||||
|
for src, dst in [("manage_service_id", "manage_service_id"),
|
||||||
|
("internet_service_id", "internet_service_id"),
|
||||||
|
("telephone_service_id", "telephone_service_id"),
|
||||||
|
("tele_service_id", "tv_service_id")]:
|
||||||
|
if fb[src]:
|
||||||
|
sets.append("{} = %({})s".format(dst, dst))
|
||||||
|
params[dst] = int(fb[src])
|
||||||
|
|
||||||
|
if fb["placemarks_id"]:
|
||||||
|
sets.append("placemarks_id = %(placemarks_id)s")
|
||||||
|
params["placemarks_id"] = str(fb["placemarks_id"])
|
||||||
|
if fb["appartements_id"]:
|
||||||
|
sets.append("apartment_building_id = %(apt_id)s")
|
||||||
|
params["apt_id"] = str(fb["appartements_id"])
|
||||||
|
|
||||||
|
if sets:
|
||||||
|
sql = 'UPDATE "tabService Location" SET {} WHERE name = %(name)s'.format(", ".join(sets))
|
||||||
|
frappe.db.sql(sql, params)
|
||||||
|
updated += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated {} locations with fibre detail".format(updated))
|
||||||
|
|
||||||
|
# Also import delivery.address2 and delivery.note
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
cur.execute("SELECT id, address2, note FROM delivery WHERE address2 IS NOT NULL OR note IS NOT NULL")
|
||||||
|
deliveries = cur.fetchall()
|
||||||
|
|
||||||
|
updated_del = 0
|
||||||
|
for d in deliveries:
|
||||||
|
loc_name = loc_map.get(d["id"])
|
||||||
|
if not loc_name:
|
||||||
|
continue
|
||||||
|
sets = []
|
||||||
|
params = {"name": loc_name}
|
||||||
|
if d["address2"]:
|
||||||
|
sets.append("address_line_2 = %(addr2)s")
|
||||||
|
params["addr2"] = str(d["address2"]).strip()
|
||||||
|
if d["note"]:
|
||||||
|
sets.append("delivery_notes = %(note)s")
|
||||||
|
params["note"] = str(d["note"])
|
||||||
|
if sets:
|
||||||
|
sql = 'UPDATE "tabService Location" SET {} WHERE name = %(name)s'.format(", ".join(sets))
|
||||||
|
frappe.db.sql(sql, params)
|
||||||
|
updated_del += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("Updated {} locations with address2/notes".format(updated_del))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 5. DONE
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
conn.close()
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("MIGRATION COMPLETE")
|
||||||
|
print("=" * 60)
|
||||||
|
print("Next: run migrate_provisioning_data.py for WiFi/VoIP from GenieACS")
|
||||||
202
scripts/migration/migrate_provisioning_data.py
Normal file
202
scripts/migration/migrate_provisioning_data.py
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
"""
|
||||||
|
migrate_provisioning_data.py — Migrate WiFi and VoIP provisioning data
|
||||||
|
from GenieACS MariaDB into ERPNext Service Equipment custom fields.
|
||||||
|
|
||||||
|
Sources:
|
||||||
|
- provisioning-data.json (exported from GenieACS MariaDB at 10.100.80.100)
|
||||||
|
Contains: wifi[] (1,713 entries) and voip[] (797 entries)
|
||||||
|
|
||||||
|
Matching strategy:
|
||||||
|
- WiFi: wifi.serial (Deco MAC) → Service Equipment.mac_address
|
||||||
|
- VoIP: voip.serial (RCMG serial) → Service Equipment.serial_number
|
||||||
|
|
||||||
|
Run inside erpnext-backend-1:
|
||||||
|
/home/frappe/frappe-bench/env/bin/python /home/frappe/frappe-bench/migrate_provisioning_data.py
|
||||||
|
"""
|
||||||
|
import frappe
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.chdir("/home/frappe/frappe-bench/sites")
|
||||||
|
frappe.init(site="erp.gigafibre.ca", sites_path=".")
|
||||||
|
frappe.connect()
|
||||||
|
frappe.local.flags.ignore_permissions = True
|
||||||
|
print("Connected:", frappe.local.site)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Load provisioning data export
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# This file should be copied to the bench directory from:
|
||||||
|
# scripts/migration/genieacs-export/provisioning-data.json
|
||||||
|
PROV_FILE = "/home/frappe/frappe-bench/provisioning-data.json"
|
||||||
|
if not os.path.exists(PROV_FILE):
|
||||||
|
# Try the git repo path
|
||||||
|
PROV_FILE = os.path.expanduser(
|
||||||
|
"~/frappe-bench/apps/gigafibre-fsm/scripts/migration/genieacs-export/provisioning-data.json"
|
||||||
|
)
|
||||||
|
if not os.path.exists(PROV_FILE):
|
||||||
|
print("ERROR: provisioning-data.json not found. Copy it to /home/frappe/frappe-bench/")
|
||||||
|
print("Source: scripts/migration/genieacs-export/provisioning-data.json")
|
||||||
|
exit(1)
|
||||||
|
|
||||||
|
with open(PROV_FILE) as f:
|
||||||
|
prov_data = json.load(f)
|
||||||
|
|
||||||
|
wifi_entries = prov_data.get("wifi", [])
|
||||||
|
voip_entries = prov_data.get("voip", [])
|
||||||
|
print("WiFi entries: {}".format(len(wifi_entries)))
|
||||||
|
print("VoIP entries: {}".format(len(voip_entries)))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# Build MAC → Equipment Name map
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\nBuilding MAC address and serial number maps...")
|
||||||
|
|
||||||
|
# MAC address → Service Equipment name (normalized to uppercase, no colons)
|
||||||
|
mac_map = {}
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT name, mac_address FROM "tabService Equipment"
|
||||||
|
WHERE mac_address IS NOT NULL AND mac_address != ''
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
mac_clean = r["mac_address"].upper().replace(":", "").replace("-", "").replace(".", "")
|
||||||
|
mac_map[mac_clean] = r["name"]
|
||||||
|
print(" MAC map: {} entries".format(len(mac_map)))
|
||||||
|
|
||||||
|
# Serial number → Service Equipment name
|
||||||
|
serial_map = {}
|
||||||
|
rows = frappe.db.sql("""
|
||||||
|
SELECT name, serial_number FROM "tabService Equipment"
|
||||||
|
WHERE serial_number IS NOT NULL AND serial_number != ''
|
||||||
|
""", as_dict=True)
|
||||||
|
for r in rows:
|
||||||
|
serial_map[r["serial_number"].upper()] = r["name"]
|
||||||
|
print(" Serial map: {} entries".format(len(serial_map)))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 1. WiFi provisioning → Service Equipment
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("1. WiFi PROVISIONING")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Group wifi entries by serial (keep latest / instance=1)
|
||||||
|
wifi_by_serial = {}
|
||||||
|
for w in wifi_entries:
|
||||||
|
serial = w.get("serial", "")
|
||||||
|
if not serial:
|
||||||
|
continue
|
||||||
|
instance = w.get("instance", 1)
|
||||||
|
# Prefer instance 1 (primary SSID)
|
||||||
|
if serial not in wifi_by_serial or instance == 1:
|
||||||
|
wifi_by_serial[serial] = w
|
||||||
|
print("Unique WiFi serials: {}".format(len(wifi_by_serial)))
|
||||||
|
|
||||||
|
matched_wifi = 0
|
||||||
|
unmatched_wifi = 0
|
||||||
|
|
||||||
|
for serial, w in wifi_by_serial.items():
|
||||||
|
ssid = w.get("ssid", "")
|
||||||
|
password = w.get("password", "")
|
||||||
|
if not ssid:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# WiFi serial is typically a MAC address (Deco) — try MAC map
|
||||||
|
mac_clean = serial.upper().replace(":", "").replace("-", "").replace(".", "")
|
||||||
|
eq_name = mac_map.get(mac_clean)
|
||||||
|
|
||||||
|
# Also try serial number map
|
||||||
|
if not eq_name:
|
||||||
|
eq_name = serial_map.get(serial.upper())
|
||||||
|
|
||||||
|
# Try with common prefix patterns (TPLG...)
|
||||||
|
if not eq_name:
|
||||||
|
for prefix in ["TPLG", "TPLINK"]:
|
||||||
|
eq_name = serial_map.get(prefix + serial.upper())
|
||||||
|
if eq_name:
|
||||||
|
break
|
||||||
|
|
||||||
|
if not eq_name:
|
||||||
|
unmatched_wifi += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabService Equipment"
|
||||||
|
SET wifi_ssid = %(ssid)s, wifi_password = %(password)s
|
||||||
|
WHERE name = %(name)s
|
||||||
|
""", {"name": eq_name, "ssid": ssid, "password": password})
|
||||||
|
matched_wifi += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("WiFi matched: {} / unmatched: {}".format(matched_wifi, unmatched_wifi))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 2. VoIP provisioning → Service Equipment
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("2. VoIP PROVISIONING")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Group voip entries by serial
|
||||||
|
voip_by_serial = {}
|
||||||
|
for v in voip_entries:
|
||||||
|
serial = v.get("serial", "")
|
||||||
|
if not serial:
|
||||||
|
continue
|
||||||
|
instance = v.get("instance", 1)
|
||||||
|
if serial not in voip_by_serial or instance == 1:
|
||||||
|
voip_by_serial[serial] = v
|
||||||
|
print("Unique VoIP serials: {}".format(len(voip_by_serial)))
|
||||||
|
|
||||||
|
matched_voip = 0
|
||||||
|
unmatched_voip = 0
|
||||||
|
|
||||||
|
for serial, v in voip_by_serial.items():
|
||||||
|
username = v.get("username", "")
|
||||||
|
password = v.get("password", "")
|
||||||
|
if not username:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# VoIP serial is typically RCMG physical serial — try serial map
|
||||||
|
eq_name = serial_map.get(serial.upper())
|
||||||
|
|
||||||
|
# Also try with RCMG prefix
|
||||||
|
if not eq_name and not serial.upper().startswith("RCMG"):
|
||||||
|
eq_name = serial_map.get("RCMG" + serial.upper())
|
||||||
|
|
||||||
|
# Try MAC map (some CWMP serials encode MAC)
|
||||||
|
if not eq_name:
|
||||||
|
# Extract last 12 chars as potential MAC
|
||||||
|
if len(serial) >= 12:
|
||||||
|
potential_mac = serial[-12:].upper()
|
||||||
|
eq_name = mac_map.get(potential_mac)
|
||||||
|
|
||||||
|
if not eq_name:
|
||||||
|
unmatched_voip += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
frappe.db.sql("""
|
||||||
|
UPDATE "tabService Equipment"
|
||||||
|
SET sip_username = %(username)s, sip_password = %(password)s
|
||||||
|
WHERE name = %(name)s
|
||||||
|
""", {"name": eq_name, "username": username, "password": password})
|
||||||
|
matched_voip += 1
|
||||||
|
|
||||||
|
frappe.db.commit()
|
||||||
|
print("VoIP matched: {} / unmatched: {}".format(matched_voip, unmatched_voip))
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
# 3. DONE
|
||||||
|
# ═══════════════════════════════════════════════════════════════
|
||||||
|
print("\n" + "=" * 60)
|
||||||
|
print("PROVISIONING MIGRATION COMPLETE")
|
||||||
|
print("=" * 60)
|
||||||
|
print("WiFi: {} matched, {} unmatched".format(matched_wifi, unmatched_wifi))
|
||||||
|
print("VoIP: {} matched, {} unmatched".format(matched_voip, unmatched_voip))
|
||||||
|
print("")
|
||||||
|
print("Unmatched devices will be resolved after OLT query tagging (see ANALYSIS.md)")
|
||||||
|
|
@ -155,6 +155,13 @@ function erpFetch (path, opts = {}) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Convenience wrapper: erpRequest(method, path, body?) → { status, data }
|
||||||
|
async function erpRequest (method, path, body) {
|
||||||
|
const opts = { method }
|
||||||
|
if (body) opts.body = body
|
||||||
|
return erpFetch(path, opts)
|
||||||
|
}
|
||||||
|
|
||||||
async function lookupCustomerByPhone (phone) {
|
async function lookupCustomerByPhone (phone) {
|
||||||
// Normalize to last 10 digits
|
// Normalize to last 10 digits
|
||||||
const digits = phone.replace(/\D/g, '').slice(-10)
|
const digits = phone.replace(/\D/g, '').slice(-10)
|
||||||
|
|
@ -688,6 +695,12 @@ const server = http.createServer(async (req, res) => {
|
||||||
return handleACSConfig(req, res, method, path, url)
|
return handleACSConfig(req, res, method, path, url)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Installation / Provisioning API ───
|
||||||
|
// OLT pre-authorization, equipment activation, barcode-triggered workflows
|
||||||
|
if (path.startsWith('/provision/')) {
|
||||||
|
return handleProvisioning(req, res, method, path, url)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── 404 ───
|
// ─── 404 ───
|
||||||
json(res, 404, { error: 'Not found' })
|
json(res, 404, { error: 'Not found' })
|
||||||
|
|
||||||
|
|
@ -1372,6 +1385,101 @@ function genieRequest (method, path, body) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Find the WAN (public) IP by scanning all IP interfaces for a non-RFC1918 address
|
||||||
|
function findWanIp (d) {
|
||||||
|
const ifaces = extractAllInterfaces(d)
|
||||||
|
const pub = ifaces.find(i => i.role === 'internet')
|
||||||
|
return pub ? pub.ip : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract all active IP interfaces with role classification
|
||||||
|
function extractAllInterfaces (d) {
|
||||||
|
const ipIfaces = d.Device && d.Device.IP && d.Device.IP.Interface
|
||||||
|
if (!ipIfaces) return []
|
||||||
|
const results = []
|
||||||
|
for (const ifKey of Object.keys(ipIfaces)) {
|
||||||
|
if (ifKey.startsWith('_')) continue
|
||||||
|
const iface = ipIfaces[ifKey]
|
||||||
|
if (!iface || !iface.IPv4Address) continue
|
||||||
|
const name = iface.Name && iface.Name._value || ''
|
||||||
|
for (const addrKey of Object.keys(iface.IPv4Address)) {
|
||||||
|
if (addrKey.startsWith('_')) continue
|
||||||
|
const addr = iface.IPv4Address[addrKey]
|
||||||
|
if (!addr || !addr.IPAddress || !addr.IPAddress._value) continue
|
||||||
|
const ip = addr.IPAddress._value
|
||||||
|
const status = addr.Status && addr.Status._value
|
||||||
|
if (!ip || ip === '0.0.0.0' || (status && status !== 'Enabled')) continue
|
||||||
|
const mask = addr.SubnetMask && addr.SubnetMask._value || ''
|
||||||
|
const addrType = addr.AddressingType && addr.AddressingType._value || ''
|
||||||
|
// Classify by IP range
|
||||||
|
let role = 'unknown'
|
||||||
|
if (ip.startsWith('192.168.') || (ip.startsWith('10.') && name === 'br0')) role = 'lan'
|
||||||
|
else if (!ip.startsWith('10.') && !ip.startsWith('172.') && !ip.startsWith('192.168.') && !ip.startsWith('169.254.')) role = 'internet'
|
||||||
|
else if (ip.startsWith('172.17.') || ip.startsWith('172.16.') || name.includes('_10')) role = 'management'
|
||||||
|
else role = 'service'
|
||||||
|
results.push({ iface: ifKey, name, ip, mask, addrType, role })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return results
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sum all WiFi AccessPoint associated device counts (controller only, flat AP tree)
|
||||||
|
function countAllWifiClients (d) {
|
||||||
|
const aps = d.Device && d.Device.WiFi && d.Device.WiFi.AccessPoint
|
||||||
|
if (!aps) return { direct: 0, mesh: 0, total: 0, perAp: [] }
|
||||||
|
let direct = 0, mesh = 0
|
||||||
|
const perAp = []
|
||||||
|
for (const k of Object.keys(aps)) {
|
||||||
|
if (k.startsWith('_') || !aps[k]) continue
|
||||||
|
const cnt = aps[k].AssociatedDeviceNumberOfEntries
|
||||||
|
if (!cnt || cnt._value === undefined) continue
|
||||||
|
const n = Number(cnt._value) || 0
|
||||||
|
const ssidRef = aps[k].SSIDReference && aps[k].SSIDReference._value || ''
|
||||||
|
const ssidIdx = ssidRef.match(/SSID\.(\d+)/)
|
||||||
|
const idx = ssidIdx ? parseInt(ssidIdx[1]) : parseInt(k)
|
||||||
|
if (idx <= 8) { direct += n } else { mesh += n }
|
||||||
|
if (n > 0) perAp.push({ ap: k, ssid: ssidRef, clients: n })
|
||||||
|
}
|
||||||
|
return { direct, mesh, total: direct + mesh, perAp }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract EasyMesh topology from MultiAP tree — returns mesh nodes + true client totals
|
||||||
|
function extractMeshTopology (d) {
|
||||||
|
const apDevices = d.Device && d.Device.WiFi && d.Device.WiFi.MultiAP && d.Device.WiFi.MultiAP.APDevice
|
||||||
|
if (!apDevices) return null
|
||||||
|
const nodes = []
|
||||||
|
let totalClients = 0
|
||||||
|
for (const dk of Object.keys(apDevices)) {
|
||||||
|
if (dk.startsWith('_')) continue
|
||||||
|
const dev = apDevices[dk]
|
||||||
|
if (!dev || !dev._object) continue
|
||||||
|
const name = dev.X_TP_HostName && dev.X_TP_HostName._value || ''
|
||||||
|
const active = dev.X_TP_Active && dev.X_TP_Active._value
|
||||||
|
const mac = dev.MACAddress && dev.MACAddress._value || ''
|
||||||
|
const ip = dev.X_TP_IPAddress && dev.X_TP_IPAddress._value || ''
|
||||||
|
// Sum clients across all radios and APs for this node
|
||||||
|
let nodeClients = 0
|
||||||
|
const radios = dev.Radio || {}
|
||||||
|
for (const rk of Object.keys(radios)) {
|
||||||
|
if (rk.startsWith('_')) continue
|
||||||
|
const radio = radios[rk]
|
||||||
|
if (!radio || !radio.AP) continue
|
||||||
|
for (const ak of Object.keys(radio.AP)) {
|
||||||
|
if (ak.startsWith('_')) continue
|
||||||
|
const ap = radio.AP[ak]
|
||||||
|
if (!ap) continue
|
||||||
|
const cnt = ap.AssociatedDeviceNumberOfEntries
|
||||||
|
if (cnt && cnt._value !== undefined) nodeClients += Number(cnt._value) || 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
totalClients += nodeClients
|
||||||
|
if (name || mac) {
|
||||||
|
nodes.push({ id: dk, name, active: active === true, mac, ip, clients: nodeClients })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nodes.length > 0 ? { nodes, totalClients } : null
|
||||||
|
}
|
||||||
|
|
||||||
// Extract key parameters from a GenieACS device object into a flat summary
|
// Extract key parameters from a GenieACS device object into a flat summary
|
||||||
function summarizeDevice (d) {
|
function summarizeDevice (d) {
|
||||||
const get = (path) => {
|
const get = (path) => {
|
||||||
|
|
@ -1393,14 +1501,17 @@ function summarizeDevice (d) {
|
||||||
lastInform: d['_lastInform'] || null,
|
lastInform: d['_lastInform'] || null,
|
||||||
lastBoot: d['_lastBootstrap'] || null,
|
lastBoot: d['_lastBootstrap'] || null,
|
||||||
registered: d['_registered'] || null,
|
registered: d['_registered'] || null,
|
||||||
|
interfaces: extractAllInterfaces(d),
|
||||||
ip: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress')
|
ip: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANIPConnection.1.ExternalIPAddress')
|
||||||
|| getStr('Device.IP.Interface.1.IPv4Address.1.IPAddress') || '',
|
|| findWanIp(d) || '',
|
||||||
rxPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower')
|
rxPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.RXPower')
|
||||||
|| get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.RXPower')
|
|| get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.RXPower')
|
||||||
|| get('Device.Optical.Interface.1.Stats.SignalRxPower') || null,
|
|| get('Device.Optical.Interface.1.Stats.SignalRxPower')
|
||||||
|
|| get('Device.Optical.Interface.1.OpticalSignalLevel') || null,
|
||||||
txPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.TXPower')
|
txPower: get('InternetGatewayDevice.WANDevice.1.X_GponInterafceConfig.TXPower')
|
||||||
|| get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.TXPower')
|
|| get('InternetGatewayDevice.WANDevice.1.WANCommonInterfaceConfig.TXPower')
|
||||||
|| get('Device.Optical.Interface.1.Stats.SignalTxPower') || null,
|
|| get('Device.Optical.Interface.1.Stats.SignalTxPower')
|
||||||
|
|| get('Device.Optical.Interface.1.TransmitOpticalLevel') || null,
|
||||||
pppoeUser: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username')
|
pppoeUser: getStr('InternetGatewayDevice.WANDevice.1.WANConnectionDevice.1.WANPPPConnection.1.Username')
|
||||||
|| getStr('Device.PPP.Interface.1.Username') || '',
|
|| getStr('Device.PPP.Interface.1.Username') || '',
|
||||||
ssid: getStr('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID')
|
ssid: getStr('InternetGatewayDevice.LANDevice.1.WLANConfiguration.1.SSID')
|
||||||
|
|
@ -1408,6 +1519,51 @@ function summarizeDevice (d) {
|
||||||
macAddress: getStr('InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress')
|
macAddress: getStr('InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress')
|
||||||
|| getStr('Device.Ethernet.Interface.1.MACAddress') || '',
|
|| getStr('Device.Ethernet.Interface.1.MACAddress') || '',
|
||||||
tags: d['_tags'] || [],
|
tags: d['_tags'] || [],
|
||||||
|
// ── Extended diagnostics (XX230v / TR-181 devices) ──
|
||||||
|
opticalStatus: getStr('Device.Optical.Interface.1.Status') || null,
|
||||||
|
opticalErrors: {
|
||||||
|
sent: get('Device.Optical.Interface.1.Stats.ErrorsSent') || 0,
|
||||||
|
received: get('Device.Optical.Interface.1.Stats.ErrorsReceived') || 0,
|
||||||
|
},
|
||||||
|
wifi: (() => {
|
||||||
|
const counts = countAllWifiClients(d)
|
||||||
|
const mesh = extractMeshTopology(d)
|
||||||
|
return {
|
||||||
|
radio1: {
|
||||||
|
status: getStr('Device.WiFi.Radio.1.Status') || null,
|
||||||
|
channel: get('Device.WiFi.Radio.1.Channel') || null,
|
||||||
|
bandwidth: getStr('Device.WiFi.Radio.1.CurrentOperatingChannelBandwidth') || null,
|
||||||
|
noise: get('Device.WiFi.Radio.1.Stats.Noise') || null,
|
||||||
|
clients: get('Device.WiFi.AccessPoint.1.AssociatedDeviceNumberOfEntries') || 0,
|
||||||
|
},
|
||||||
|
radio2: {
|
||||||
|
status: getStr('Device.WiFi.Radio.2.Status') || null,
|
||||||
|
channel: get('Device.WiFi.Radio.2.Channel') || null,
|
||||||
|
bandwidth: getStr('Device.WiFi.Radio.2.CurrentOperatingChannelBandwidth') || null,
|
||||||
|
noise: get('Device.WiFi.Radio.2.Stats.Noise') || null,
|
||||||
|
clients: get('Device.WiFi.AccessPoint.2.AssociatedDeviceNumberOfEntries') || 0,
|
||||||
|
},
|
||||||
|
radio3: {
|
||||||
|
status: getStr('Device.WiFi.Radio.3.Status') || null,
|
||||||
|
channel: get('Device.WiFi.Radio.3.Channel') || null,
|
||||||
|
clients: get('Device.WiFi.AccessPoint.3.AssociatedDeviceNumberOfEntries') || 0,
|
||||||
|
},
|
||||||
|
// Use mesh totals if available (more accurate), fall back to flat AP counts
|
||||||
|
totalClients: mesh ? mesh.totalClients : counts.total,
|
||||||
|
directClients: counts.direct,
|
||||||
|
meshClients: mesh ? (mesh.totalClients - counts.direct) : counts.mesh,
|
||||||
|
}
|
||||||
|
})(),
|
||||||
|
// EasyMesh topology — each node with name, IP, client count
|
||||||
|
mesh: (() => {
|
||||||
|
const mesh = extractMeshTopology(d)
|
||||||
|
return mesh ? mesh.nodes : null
|
||||||
|
})(),
|
||||||
|
hostsCount: get('Device.Hosts.HostNumberOfEntries') || null,
|
||||||
|
ethernet: {
|
||||||
|
port1: { status: getStr('Device.Ethernet.Interface.1.Status') || null, speed: get('Device.Ethernet.Interface.1.MaxBitRate') || null },
|
||||||
|
port2: { status: getStr('Device.Ethernet.Interface.2.Status') || null, speed: get('Device.Ethernet.Interface.2.MaxBitRate') || null },
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1445,13 +1601,40 @@ async function handleGenieACS (req, res, method, path, url) {
|
||||||
const mac = url.searchParams.get('mac')
|
const mac = url.searchParams.get('mac')
|
||||||
if (!serial && !mac) return json(res, 400, { error: 'Provide serial or mac parameter' })
|
if (!serial && !mac) return json(res, 400, { error: 'Provide serial or mac parameter' })
|
||||||
|
|
||||||
let query = ''
|
const projection = 'DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags'
|
||||||
if (serial) query = `DeviceID.SerialNumber = "${serial}"`
|
let devices = []
|
||||||
else if (mac) query = `InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress = "${mac}" OR Device.Ethernet.Interface.1.MACAddress = "${mac}"`
|
|
||||||
|
if (serial) {
|
||||||
|
// Try 1: exact SerialNumber match (works for Raisecom HT803G etc.)
|
||||||
|
let query = JSON.stringify({ 'DeviceID.SerialNumber._value': serial })
|
||||||
|
let result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`)
|
||||||
|
devices = Array.isArray(result.data) ? result.data : []
|
||||||
|
|
||||||
|
// Try 2: GPON serial match (XX230v stores TPLG + last 8 hex of GponSn as serial in ERPNext)
|
||||||
|
if (!devices.length && serial.startsWith('TPLG')) {
|
||||||
|
const gponSuffix = serial.slice(4).toUpperCase()
|
||||||
|
query = JSON.stringify({ 'Device.Optical.Interface.1.GponAuth.GponSn._value': { '$regex': gponSuffix + '$' } })
|
||||||
|
result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`)
|
||||||
|
devices = Array.isArray(result.data) ? result.data : []
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try 3: _id contains the serial (fallback)
|
||||||
|
if (!devices.length) {
|
||||||
|
query = JSON.stringify({ '_id': { '$regex': serial } })
|
||||||
|
result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`)
|
||||||
|
devices = Array.isArray(result.data) ? result.data : []
|
||||||
|
}
|
||||||
|
} else if (mac) {
|
||||||
|
const cleanMac = mac.replace(/[:-]/g, '').toUpperCase()
|
||||||
|
const query = JSON.stringify({ '$or': [
|
||||||
|
{ 'InternetGatewayDevice.WANDevice.1.WANEthernetInterfaceConfig.MACAddress._value': mac },
|
||||||
|
{ 'Device.Ethernet.Interface.1.MACAddress._value': mac },
|
||||||
|
{ 'Device.DeviceInfo.X_TP_MACAddress._value': { '$regex': cleanMac.slice(-6) } },
|
||||||
|
]})
|
||||||
|
const result = await genieRequest('GET', `/devices/?query=${encodeURIComponent(query)}&projection=${projection}`)
|
||||||
|
devices = Array.isArray(result.data) ? result.data : []
|
||||||
|
}
|
||||||
|
|
||||||
const result = await genieRequest('GET',
|
|
||||||
`/devices/?query=${encodeURIComponent(query)}&projection=DeviceID,InternetGatewayDevice,Device,_lastInform,_lastBootstrap,_registered,_tags`)
|
|
||||||
const devices = Array.isArray(result.data) ? result.data : []
|
|
||||||
return json(res, 200, devices.map(summarizeDevice))
|
return json(res, 200, devices.map(summarizeDevice))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1515,6 +1698,153 @@ async function handleGenieACS (req, res, method, path, url) {
|
||||||
return json(res, result.status, result.data)
|
return json(res, result.status, result.data)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GET /devices/:id/hosts — get connected clients (LAN hosts + DHCP leases)
|
||||||
|
if (subResource === 'hosts' && method === 'GET') {
|
||||||
|
const encId = encodeURIComponent(deviceId)
|
||||||
|
|
||||||
|
// Step 1: Send getParameterValues for hosts — triggers connection_request + provision
|
||||||
|
const hostParams = []
|
||||||
|
for (let i = 1; i <= 20; i++) {
|
||||||
|
for (const f of ['HostName', 'IPAddress', 'PhysAddress', 'Active', 'AddressSource',
|
||||||
|
'LeaseTimeRemaining', 'Layer1Interface']) {
|
||||||
|
hostParams.push(`Device.Hosts.Host.${i}.${f}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hostParams.push('Device.Hosts.HostNumberOfEntries')
|
||||||
|
try {
|
||||||
|
await genieRequest('POST', `/devices/${encId}/tasks?connection_request&timeout=15000`,
|
||||||
|
{ name: 'getParameterValues', parameterNames: hostParams })
|
||||||
|
} catch (e) { /* timeout ok */ }
|
||||||
|
|
||||||
|
// Step 2: Now send a SECOND task for MultiAP associated device MACs
|
||||||
|
// The device session is still open from step 1, so this runs immediately
|
||||||
|
// This reads which client MAC is connected to which mesh node AP
|
||||||
|
const multiApParams = []
|
||||||
|
for (let d2 = 1; d2 <= 3; d2++) { // up to 3 mesh nodes
|
||||||
|
for (let r = 1; r <= 2; r++) { // 2 radios per node
|
||||||
|
for (let a = 1; a <= 4; a++) { // up to 4 APs per radio
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDeviceNumberOfEntries`)
|
||||||
|
for (let c = 1; c <= 8; c++) { // up to 8 clients per AP
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.MACAddress`)
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.SignalStrength`)
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.${r}.AP.${a}.AssociatedDevice.${c}.X_TP_NegotiationSpeed`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Also fetch node info
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.X_TP_HostName`)
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.MACAddress`)
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.X_TP_Active`)
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.1.OperatingFrequencyBand`)
|
||||||
|
multiApParams.push(`Device.WiFi.MultiAP.APDevice.${d2}.Radio.2.OperatingFrequencyBand`)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await genieRequest('POST', `/devices/${encId}/tasks?timeout=10000`,
|
||||||
|
{ name: 'getParameterValues', parameterNames: multiApParams })
|
||||||
|
} catch (e) { /* timeout ok */ }
|
||||||
|
|
||||||
|
// Step 3: Read the now-cached data from GenieACS
|
||||||
|
const result = await genieRequest('GET',
|
||||||
|
`/devices/?query=${encodeURIComponent(JSON.stringify({ _id: deviceId }))}&projection=Device.Hosts,Device.WiFi.MultiAP`)
|
||||||
|
const devices = Array.isArray(result.data) ? result.data : []
|
||||||
|
if (!devices.length) return json(res, 404, { error: 'Device not found' })
|
||||||
|
|
||||||
|
const dd = devices[0]
|
||||||
|
const hostTree = dd.Device && dd.Device.Hosts && dd.Device.Hosts.Host || {}
|
||||||
|
const hostCount = dd.Device && dd.Device.Hosts && dd.Device.Hosts.HostNumberOfEntries
|
||||||
|
&& dd.Device.Hosts.HostNumberOfEntries._value || 0
|
||||||
|
|
||||||
|
// Step 4: Build client MAC → { nodeName, band, signal, speed } from MultiAP
|
||||||
|
const clientNodeMap = new Map() // client MAC (upper) → { nodeName, band, signal, speed }
|
||||||
|
const meshNodes = new Map() // node MAC (upper) → { name, ip }
|
||||||
|
const apDevices = dd.Device && dd.Device.WiFi && dd.Device.WiFi.MultiAP && dd.Device.WiFi.MultiAP.APDevice || {}
|
||||||
|
for (const dk of Object.keys(apDevices)) {
|
||||||
|
if (dk.startsWith('_')) continue
|
||||||
|
const dev = apDevices[dk]
|
||||||
|
const nodeMac = dev.MACAddress && dev.MACAddress._value || ''
|
||||||
|
const nodeName = dev.X_TP_HostName && dev.X_TP_HostName._value || ''
|
||||||
|
const nodeIp = dev.X_TP_IPAddress && dev.X_TP_IPAddress._value || ''
|
||||||
|
if (nodeMac) meshNodes.set(nodeMac.toUpperCase(), { name: nodeName, ip: nodeIp })
|
||||||
|
|
||||||
|
// Walk radios → APs → AssociatedDevices
|
||||||
|
const radios = dev.Radio || {}
|
||||||
|
for (const rk of Object.keys(radios)) {
|
||||||
|
if (rk.startsWith('_')) continue
|
||||||
|
const radio = radios[rk]
|
||||||
|
if (!radio) continue
|
||||||
|
const freqBand = radio.OperatingFrequencyBand && radio.OperatingFrequencyBand._value || ''
|
||||||
|
const band = freqBand.includes('5') ? '5GHz' : freqBand.includes('2.4') ? '2.4GHz' : freqBand || ''
|
||||||
|
const aps = radio.AP || {}
|
||||||
|
for (const ak of Object.keys(aps)) {
|
||||||
|
if (ak.startsWith('_')) continue
|
||||||
|
const ap = aps[ak]
|
||||||
|
if (!ap) continue
|
||||||
|
const assocDevs = ap.AssociatedDevice || {}
|
||||||
|
for (const ck of Object.keys(assocDevs)) {
|
||||||
|
if (ck.startsWith('_')) continue
|
||||||
|
const client = assocDevs[ck]
|
||||||
|
if (!client) continue
|
||||||
|
const cMac = client.MACAddress && client.MACAddress._value
|
||||||
|
if (!cMac) continue
|
||||||
|
const signal = client.SignalStrength && client.SignalStrength._value
|
||||||
|
const speed = client.X_TP_NegotiationSpeed && client.X_TP_NegotiationSpeed._value
|
||||||
|
clientNodeMap.set(cMac.toUpperCase(), {
|
||||||
|
nodeName, nodeMac: nodeMac.toUpperCase(), band,
|
||||||
|
signal: signal != null ? Number(signal) : null,
|
||||||
|
speed: speed != null ? String(speed) : '',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Build host list, enriched with node mapping
|
||||||
|
const hosts = []
|
||||||
|
for (const k of Object.keys(hostTree)) {
|
||||||
|
if (k.startsWith('_')) continue
|
||||||
|
const h = hostTree[k]
|
||||||
|
if (!h || h._object === undefined) continue
|
||||||
|
const gv = (key) => h[key] && h[key]._value !== undefined ? h[key]._value : null
|
||||||
|
const name = gv('HostName')
|
||||||
|
const ip = gv('IPAddress')
|
||||||
|
const mac = gv('PhysAddress')
|
||||||
|
if (!name && !ip && !mac) continue
|
||||||
|
const active = gv('Active')
|
||||||
|
const source = gv('AddressSource')
|
||||||
|
const lease = gv('LeaseTimeRemaining')
|
||||||
|
const isMeshNode = mac && meshNodes.has(mac.toUpperCase())
|
||||||
|
|
||||||
|
// Look up client → node mapping from MultiAP
|
||||||
|
const nodeInfo = mac ? clientNodeMap.get(mac.toUpperCase()) : null
|
||||||
|
const attachedNode = nodeInfo ? nodeInfo.nodeName : ''
|
||||||
|
const band = nodeInfo ? nodeInfo.band : ''
|
||||||
|
const signal = nodeInfo ? nodeInfo.signal : null
|
||||||
|
const linkRate = nodeInfo ? nodeInfo.speed : ''
|
||||||
|
|
||||||
|
let connType = 'wifi'
|
||||||
|
if (isMeshNode) connType = 'mesh-node'
|
||||||
|
else if (!nodeInfo && !active) connType = 'unknown'
|
||||||
|
|
||||||
|
hosts.push({
|
||||||
|
id: k, name: name || '', ip: ip || '', mac: mac || '',
|
||||||
|
active: active === true,
|
||||||
|
addressSource: source || '',
|
||||||
|
leaseRemaining: lease != null ? Number(lease) : null,
|
||||||
|
connType, band, signal, linkRate, attachedNode,
|
||||||
|
attachedMac: nodeInfo ? nodeInfo.nodeMac : '',
|
||||||
|
isMeshNode,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts.sort((a, b) => {
|
||||||
|
if (a.isMeshNode !== b.isMeshNode) return a.isMeshNode ? 1 : -1
|
||||||
|
if (a.active !== b.active) return a.active ? -1 : 1
|
||||||
|
return (a.name || a.ip).localeCompare(b.name || b.ip)
|
||||||
|
})
|
||||||
|
|
||||||
|
return json(res, 200, { total: hostCount, hosts })
|
||||||
|
}
|
||||||
|
|
||||||
// GET /devices/:id/faults — get device faults
|
// GET /devices/:id/faults — get device faults
|
||||||
if (subResource === 'faults' && method === 'GET') {
|
if (subResource === 'faults' && method === 'GET') {
|
||||||
const result = await genieRequest('GET',
|
const result = await genieRequest('GET',
|
||||||
|
|
@ -1541,6 +1871,308 @@ async function handleGenieACS (req, res, method, path, url) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
// Provisioning API — OLT pre-auth, equipment activation, swap
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════
|
||||||
|
//
|
||||||
|
// These endpoints are called by:
|
||||||
|
// - n8n webhooks (on task open/close)
|
||||||
|
// - Field tech app (on barcode scan)
|
||||||
|
// - Ops app (manual activation)
|
||||||
|
//
|
||||||
|
// OLT pre-authorization flow:
|
||||||
|
// When we know the address→OLT→slot→port mapping AND the ONT GPON serial
|
||||||
|
// (scanned in warehouse or on-site), we can pre-register the ONT on the OLT
|
||||||
|
// so it auto-activates the moment the tech plugs the fibre in.
|
||||||
|
//
|
||||||
|
// For XX230v: GPON serial = "TPLG" + last 8 chars of Device.Optical.Interface.1.GponAuth.GponSn
|
||||||
|
// For Raisecom: GPON serial = RCMG sticker serial
|
||||||
|
//
|
||||||
|
// Equipment swap flow:
|
||||||
|
// Tech reports defective device → scans new replacement device → system:
|
||||||
|
// 1. Marks old equipment as "Défectueux"
|
||||||
|
// 2. Creates new Service Equipment with scanned serial
|
||||||
|
// 3. Transfers all provisioning data (WiFi, VoIP, OLT port) to new device
|
||||||
|
// 4. If OLT-connected: de-registers old ONT, registers new one
|
||||||
|
// 5. ACS pushes config to new device on bootstrap
|
||||||
|
//
|
||||||
|
async function handleProvisioning (req, res, method, path, url) {
|
||||||
|
try {
|
||||||
|
const parts = path.replace('/provision/', '').split('/').filter(Boolean)
|
||||||
|
const action = parts[0]
|
||||||
|
|
||||||
|
// ─── POST /provision/pre-authorize ───
|
||||||
|
// Pre-register an ONT on the OLT before the tech goes on site
|
||||||
|
// Body: { gpon_serial, olt_ip, frame, slot, port, ont_id, vlans, equipment_name }
|
||||||
|
if (action === 'pre-authorize' && method === 'POST') {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const { gpon_serial, olt_ip, frame, slot, port, ont_id, equipment_name } = body
|
||||||
|
const vlans = body.vlans || {}
|
||||||
|
|
||||||
|
if (!gpon_serial || !olt_ip || frame === undefined || slot === undefined || port === undefined) {
|
||||||
|
return json(res, 400, { error: 'Missing required fields: gpon_serial, olt_ip, frame, slot, port' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build OLT CLI commands for Huawei/ZTE-style OLTs
|
||||||
|
// These would be executed via SSH in a real implementation (or via n8n SSH node)
|
||||||
|
const commands = [
|
||||||
|
`# OLT: ${olt_ip} — Pre-authorize ONT`,
|
||||||
|
`interface gpon ${frame}/${slot}`,
|
||||||
|
`ont add ${port} sn-auth "${gpon_serial}" omci ont-lineprofile-id ${body.line_profile || 10} ont-srvprofile-id ${body.service_profile || 10} desc "pre-auth-${equipment_name || 'unknown'}"`,
|
||||||
|
`ont confirm ${port} ont ${ont_id}`,
|
||||||
|
`quit`,
|
||||||
|
`# Service ports (VLANs)`,
|
||||||
|
vlans.internet ? `service-port auto vlan ${vlans.internet} gemport 1 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no internet vlan',
|
||||||
|
vlans.manage ? `service-port auto vlan ${vlans.manage} gemport 2 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no manage vlan',
|
||||||
|
vlans.telephone ? `service-port auto vlan ${vlans.telephone} gemport 3 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no telephone vlan',
|
||||||
|
vlans.tv ? `service-port auto vlan ${vlans.tv} gemport 4 gpon ${frame}/${slot}/${port} ont ${ont_id} transparent` : '# no tv vlan',
|
||||||
|
]
|
||||||
|
|
||||||
|
// Update ERPNext Service Equipment with GPON serial
|
||||||
|
if (equipment_name) {
|
||||||
|
try {
|
||||||
|
await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, {
|
||||||
|
gpon_serial: gpon_serial,
|
||||||
|
status: 'Actif',
|
||||||
|
})
|
||||||
|
} catch (e) {
|
||||||
|
log('ERPNext update error:', e.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// In production, this would SSH to the OLT and execute commands.
|
||||||
|
// For now, return the commands for n8n to execute via SSH node.
|
||||||
|
log(`[provision] Pre-authorize ${gpon_serial} on ${olt_ip} ${frame}/${slot}/${port} ONT:${ont_id}`)
|
||||||
|
return json(res, 200, {
|
||||||
|
status: 'commands_generated',
|
||||||
|
gpon_serial,
|
||||||
|
olt_ip,
|
||||||
|
port: `${frame}/${slot}/${port}`,
|
||||||
|
ont_id,
|
||||||
|
commands,
|
||||||
|
note: 'Execute via n8n SSH node or manual SSH to OLT',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /provision/on-scan ───
|
||||||
|
// Called when tech scans a barcode in the field app
|
||||||
|
// Triggers: equipment update + OLT pre-auth if address→OLT mapping exists
|
||||||
|
// Body: { serial, mac, equipment_type, equipment_name, customer, service_location, job_name }
|
||||||
|
if (action === 'on-scan' && method === 'POST') {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const { serial, equipment_name, service_location } = body
|
||||||
|
|
||||||
|
if (!serial) {
|
||||||
|
return json(res, 400, { error: 'Missing serial' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { serial, actions: [] }
|
||||||
|
|
||||||
|
// 1. Update equipment serial if placeholder
|
||||||
|
if (equipment_name) {
|
||||||
|
try {
|
||||||
|
await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, {
|
||||||
|
serial_number: serial,
|
||||||
|
mac_address: body.mac || null,
|
||||||
|
status: 'Actif',
|
||||||
|
})
|
||||||
|
result.actions.push({ action: 'equipment_updated', equipment_name, serial })
|
||||||
|
} catch (e) {
|
||||||
|
result.actions.push({ action: 'equipment_update_failed', error: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if we should trigger OLT pre-auth
|
||||||
|
// (if this is an ONT and we have the OLT port mapping from the Service Location)
|
||||||
|
if (service_location && (body.equipment_type === 'ONT' || serial.startsWith('TPLG') || serial.startsWith('RCMG'))) {
|
||||||
|
try {
|
||||||
|
const locRes = await erpRequest('GET',
|
||||||
|
`/api/resource/Service Location/${encodeURIComponent(service_location)}?fields=["olt_ip","olt_port","ont_id","vlan_internet","vlan_manage","vlan_telephone","vlan_tv"]`)
|
||||||
|
const loc = locRes.data || {}
|
||||||
|
|
||||||
|
if (loc.olt_ip && loc.olt_port) {
|
||||||
|
// Parse olt_port: "0/3/2 ONT:12" → frame=0, slot=3, port=2
|
||||||
|
const portMatch = (loc.olt_port || '').match(/(\d+)\/(\d+)\/(\d+)/)
|
||||||
|
if (portMatch) {
|
||||||
|
// Derive GPON serial from scanned serial
|
||||||
|
let gpon_serial = serial
|
||||||
|
// XX230v: physical serial IS the GPON serial (TPLGXXXXXXXX)
|
||||||
|
// Raisecom: physical serial IS the GPON serial (RCMGXXXXXXXX)
|
||||||
|
|
||||||
|
result.olt_pre_auth = {
|
||||||
|
gpon_serial,
|
||||||
|
olt_ip: loc.olt_ip,
|
||||||
|
frame: parseInt(portMatch[1]),
|
||||||
|
slot: parseInt(portMatch[2]),
|
||||||
|
port: parseInt(portMatch[3]),
|
||||||
|
ont_id: loc.ont_id || 0,
|
||||||
|
vlans: {
|
||||||
|
internet: loc.vlan_internet,
|
||||||
|
manage: loc.vlan_manage,
|
||||||
|
telephone: loc.vlan_telephone,
|
||||||
|
tv: loc.vlan_tv,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result.actions.push({ action: 'olt_pre_auth_ready', message: 'OLT commands generated, send to n8n for SSH execution' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
result.actions.push({ action: 'location_lookup_failed', error: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Broadcast SSE event so Ops sees the scan in real-time
|
||||||
|
if (body.customer) {
|
||||||
|
broadcast('customer:' + body.customer, 'equipment-scanned', {
|
||||||
|
serial, equipment_type: body.equipment_type, job: body.job_name,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[provision] Scan: ${serial} → ${result.actions.length} actions`)
|
||||||
|
return json(res, 200, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── POST /provision/swap ───
|
||||||
|
// Equipment swap: defective device → replacement OR diagnostic swap
|
||||||
|
// Body: { old_equipment_name, new_serial, new_mac, equipment_type, swap_type, reason, customer, service_location, job_name }
|
||||||
|
// swap_type: "permanent" (default) | "diagnostic" | "upgrade"
|
||||||
|
// - permanent: old → Défectueux, confirmed dead
|
||||||
|
// - diagnostic: old → En diagnostic, testing if it's the cause
|
||||||
|
// - upgrade: old → Retourné, replaced with newer model
|
||||||
|
if (action === 'swap' && method === 'POST') {
|
||||||
|
const body = await parseBody(req)
|
||||||
|
const { old_equipment_name, new_serial, equipment_type, customer, service_location } = body
|
||||||
|
const swapType = body.swap_type || 'permanent'
|
||||||
|
|
||||||
|
if (!old_equipment_name || !new_serial) {
|
||||||
|
return json(res, 400, { error: 'Missing old_equipment_name and new_serial' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = { old_equipment: old_equipment_name, new_serial, swap_type: swapType, actions: [] }
|
||||||
|
|
||||||
|
// Status mapping based on swap type
|
||||||
|
const oldStatusMap = {
|
||||||
|
permanent: 'Défectueux',
|
||||||
|
diagnostic: 'En diagnostic',
|
||||||
|
upgrade: 'Retourné',
|
||||||
|
}
|
||||||
|
const oldStatus = oldStatusMap[swapType] || 'Défectueux'
|
||||||
|
|
||||||
|
// 1. Get old equipment data (to transfer provisioning info)
|
||||||
|
let oldEquip = {}
|
||||||
|
try {
|
||||||
|
const r = await erpRequest('GET', `/api/resource/Service Equipment/${encodeURIComponent(old_equipment_name)}`)
|
||||||
|
oldEquip = (r.data && r.data.data) || r.data || {}
|
||||||
|
} catch (e) {
|
||||||
|
return json(res, 404, { error: 'Old equipment not found: ' + old_equipment_name })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Mark old equipment with appropriate status
|
||||||
|
try {
|
||||||
|
await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(old_equipment_name)}`, {
|
||||||
|
status: oldStatus,
|
||||||
|
notes: `${oldEquip.notes || ''}\n[${new Date().toISOString()}] ${swapType === 'diagnostic' ? 'Swap diagnostic' : 'Remplacé'} par ${new_serial}. Raison: ${body.reason || swapType}`.trim(),
|
||||||
|
})
|
||||||
|
result.actions.push({ action: 'old_status_updated', name: old_equipment_name, status: oldStatus })
|
||||||
|
} catch (e) {
|
||||||
|
result.actions.push({ action: 'status_update_failed', error: e.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Create new equipment with transferred provisioning data
|
||||||
|
const newName = 'EQ-SWAP-' + new_serial.substring(0, 10).replace(/[^A-Za-z0-9]/g, '')
|
||||||
|
try {
|
||||||
|
await erpRequest('POST', '/api/resource/Service Equipment', {
|
||||||
|
name: newName,
|
||||||
|
equipment_type: equipment_type || oldEquip.equipment_type,
|
||||||
|
serial_number: new_serial,
|
||||||
|
mac_address: body.new_mac || null,
|
||||||
|
brand: oldEquip.brand,
|
||||||
|
model: oldEquip.model,
|
||||||
|
customer: customer || oldEquip.customer,
|
||||||
|
service_location: service_location || oldEquip.service_location,
|
||||||
|
subscription: oldEquip.subscription,
|
||||||
|
status: 'Actif',
|
||||||
|
ownership: 'Gigafibre',
|
||||||
|
// Transfer provisioning data
|
||||||
|
wifi_ssid: oldEquip.wifi_ssid,
|
||||||
|
wifi_password: oldEquip.wifi_password,
|
||||||
|
sip_username: oldEquip.sip_username,
|
||||||
|
sip_password: oldEquip.sip_password,
|
||||||
|
fibre_line_profile: oldEquip.fibre_line_profile,
|
||||||
|
fibre_service_profile: oldEquip.fibre_service_profile,
|
||||||
|
parent_device: oldEquip.parent_device,
|
||||||
|
notes: `Remplacement de ${old_equipment_name} (${oldEquip.serial_number || 'N/A'}). Raison: ${body.reason || 'défectueux'}`,
|
||||||
|
})
|
||||||
|
result.actions.push({ action: 'new_equipment_created', name: newName, serial: new_serial })
|
||||||
|
} catch (e) {
|
||||||
|
result.actions.push({ action: 'create_new_failed', error: e.message })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. If ONT, prepare OLT swap commands (de-register old, register new)
|
||||||
|
if ((equipment_type || oldEquip.equipment_type) === 'ONT' && service_location) {
|
||||||
|
try {
|
||||||
|
const locRes = await erpRequest('GET',
|
||||||
|
`/api/resource/Service Location/${encodeURIComponent(service_location || oldEquip.service_location)}?fields=["olt_ip","olt_port","ont_id","vlan_internet","vlan_manage","vlan_telephone","vlan_tv"]`)
|
||||||
|
const loc = locRes.data || {}
|
||||||
|
const portMatch = (loc.olt_port || '').match(/(\d+)\/(\d+)\/(\d+)/)
|
||||||
|
|
||||||
|
if (loc.olt_ip && portMatch) {
|
||||||
|
result.olt_swap_commands = [
|
||||||
|
`# OLT: ${loc.olt_ip} — Swap ONT on ${portMatch[0]}`,
|
||||||
|
`interface gpon ${portMatch[1]}/${portMatch[2]}`,
|
||||||
|
`# Remove old ONT`,
|
||||||
|
`ont delete ${portMatch[3]} ${loc.ont_id || 0}`,
|
||||||
|
`# Register new ONT`,
|
||||||
|
`ont add ${portMatch[3]} sn-auth "${new_serial}" omci ont-lineprofile-id ${oldEquip.fibre_line_profile || 10} ont-srvprofile-id ${oldEquip.fibre_service_profile || 10} desc "swap-${newName}"`,
|
||||||
|
`ont confirm ${portMatch[3]} ont ${loc.ont_id || 0}`,
|
||||||
|
`quit`,
|
||||||
|
`# Re-create service ports (same VLANs as before)`,
|
||||||
|
loc.vlan_internet ? `service-port auto vlan ${loc.vlan_internet} gemport 1 gpon ${portMatch[1]}/${portMatch[2]}/${portMatch[3]} ont ${loc.ont_id || 0} transparent` : '',
|
||||||
|
loc.vlan_manage ? `service-port auto vlan ${loc.vlan_manage} gemport 2 gpon ${portMatch[1]}/${portMatch[2]}/${portMatch[3]} ont ${loc.ont_id || 0} transparent` : '',
|
||||||
|
].filter(Boolean)
|
||||||
|
result.actions.push({ action: 'olt_swap_commands_ready', message: 'Execute via n8n SSH node' })
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
result.actions.push({ action: 'olt_swap_lookup_failed', error: e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Broadcast SSE event
|
||||||
|
if (customer || oldEquip.customer) {
|
||||||
|
broadcast('customer:' + (customer || oldEquip.customer), 'equipment-swapped', {
|
||||||
|
old: old_equipment_name, new_serial, reason: body.reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log(`[provision] Swap: ${old_equipment_name} → ${new_serial} (${result.actions.length} actions)`)
|
||||||
|
return json(res, 200, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── GET /provision/equipment/:serial ───
|
||||||
|
// Lookup equipment by serial for provisioning data (called by ACS on bootstrap)
|
||||||
|
if (parts.length >= 2 && parts[0] === 'equipment' && method === 'GET') {
|
||||||
|
const serial = decodeURIComponent(parts[1])
|
||||||
|
try {
|
||||||
|
const r = await erpRequest('GET',
|
||||||
|
`/api/resource/Service Equipment?filters=[["serial_number","=","${serial}"]]&fields=["name","serial_number","mac_address","wifi_ssid","wifi_password","sip_username","sip_password","fibre_line_profile","fibre_service_profile","customer","service_location","equipment_type"]&limit_page_length=1`)
|
||||||
|
const equips = (r.data || [])
|
||||||
|
if (!equips.length) {
|
||||||
|
return json(res, 404, { error: 'Equipment not found for serial: ' + serial })
|
||||||
|
}
|
||||||
|
return json(res, 200, equips[0])
|
||||||
|
} catch (e) {
|
||||||
|
return json(res, 502, { error: 'ERPNext lookup failed: ' + e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return json(res, 400, { error: 'Unknown provision endpoint: ' + action })
|
||||||
|
} catch (e) {
|
||||||
|
log('Provisioning error:', e.message)
|
||||||
|
return json(res, 500, { error: 'Provisioning error: ' + e.message })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
server.listen(PORT, '0.0.0.0', () => {
|
server.listen(PORT, '0.0.0.0', () => {
|
||||||
log(`targo-hub listening on :${PORT}`)
|
log(`targo-hub listening on :${PORT}`)
|
||||||
log(` SSE: GET /sse?topics=customer:C-LPB4`)
|
log(` SSE: GET /sse?topics=customer:C-LPB4`)
|
||||||
|
|
@ -1556,6 +2188,8 @@ server.listen(PORT, '0.0.0.0', () => {
|
||||||
log(` Telephony: GET /telephony/overview`)
|
log(` Telephony: GET /telephony/overview`)
|
||||||
log(` Devices: GET /devices, /devices/summary, /devices/lookup?serial=X`)
|
log(` Devices: GET /devices, /devices/summary, /devices/lookup?serial=X`)
|
||||||
log(` Devices: GET|POST|DELETE /devices/:id/tasks`)
|
log(` Devices: GET|POST|DELETE /devices/:id/tasks`)
|
||||||
|
log(` Provision: POST /provision/pre-authorize, /provision/on-scan, /provision/swap`)
|
||||||
|
log(` Provision: GET /provision/equipment/:serial`)
|
||||||
log(` GenieACS: ${GENIEACS_NBI_URL}`)
|
log(` GenieACS: ${GENIEACS_NBI_URL}`)
|
||||||
log(` Routr DB: ${ROUTR_DB_URL.replace(/:[^:@]+@/, ':***@')}`)
|
log(` Routr DB: ${ROUTR_DB_URL.replace(/:[^:@]+@/, ':***@')}`)
|
||||||
// Start 3CX poller
|
// Start 3CX poller
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user