Backend services: - targo-hub: extract deepGetValue to helpers.js, DRY disconnect reasons lookup map, compact CAPABILITIES, consolidate vision.js prompts/schemas, extract dispatch scoring weights, trim section dividers across 9 files - modem-bridge: extract getSession() helper (6 occurrences), resetIdleTimer(), consolidate DM query factory, fix duplicate username fill bug, trim headers (server.js -36%, tplink-session.js -47%, docker-compose.yml -57%) Frontend: - useWifiDiagnostic: extract THRESHOLDS const, split processDiagnostic into 6 focused helpers (processOnlineStatus, processWanIPs, processRadios, processMeshNodes, processClients, checkRadioIssues) - EquipmentDetail: merge duplicate ROLE_LABELS, remove verbose comments Documentation (17 → 13 files, -1,400 lines): - New consolidated README.md (architecture, services, dependencies, auth) - Merge ECOSYSTEM-OVERVIEW into ARCHITECTURE.md - Merge MIGRATION-PLAN + ARCHITECTURE-COMPARE + FIELD-GAP + CHANGELOG → MIGRATION.md - Merge COMPETITIVE-ANALYSIS into PLATFORM-STRATEGY.md - Update ROADMAP.md with current phase status - Delete CONTEXT.md (absorbed into README) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
176 lines
10 KiB
JavaScript
176 lines
10 KiB
JavaScript
'use strict'
|
|
const { log, json, parseBody, erpRequest } = require('./helpers')
|
|
const { broadcast } = require('./sse')
|
|
let oktopus
|
|
try { oktopus = require('./oktopus') } catch (e) { log('Oktopus module not loaded:', e.message) }
|
|
|
|
async function handle (req, res, method, path) {
|
|
try {
|
|
const action = path.replace('/provision/', '').split('/').filter(Boolean)[0]
|
|
|
|
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' })
|
|
}
|
|
|
|
const vlanCmd = (vlan, gemport) => vlan
|
|
? `service-port auto vlan ${vlan} gemport ${gemport} gpon ${frame}/${slot}/${port} ont ${ont_id} transparent`
|
|
: `# no vlan for gemport ${gemport}`
|
|
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)`,
|
|
vlanCmd(vlans.internet, 1), vlanCmd(vlans.manage, 2), vlanCmd(vlans.telephone, 3), vlanCmd(vlans.tv, 4),
|
|
]
|
|
|
|
if (equipment_name) {
|
|
try { await erpRequest('PUT', `/api/resource/Service Equipment/${encodeURIComponent(equipment_name)}`, { gpon_serial, status: 'Actif' }) }
|
|
catch (e) { log('ERPNext update error:', e.message) }
|
|
}
|
|
|
|
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' })
|
|
}
|
|
|
|
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: [] }
|
|
|
|
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 }) }
|
|
}
|
|
|
|
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) {
|
|
const m = (loc.olt_port || '').match(/(\d+)\/(\d+)\/(\d+)/)
|
|
if (m) {
|
|
result.olt_pre_auth = { gpon_serial: serial, olt_ip: loc.olt_ip, frame: +m[1], slot: +m[2], port: +m[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 }) }
|
|
}
|
|
|
|
if (oktopus && body.mac && (body.equipment_type === 'ONT' || serial.startsWith('TPLG') || serial.startsWith('RCMG'))) {
|
|
try {
|
|
const oktRes = await oktopus.provisionDevice({
|
|
mac: body.mac,
|
|
serial,
|
|
service_location: body.service_location,
|
|
equipment_name: body.equipment_name,
|
|
customer: body.customer,
|
|
})
|
|
result.oktopus = oktRes
|
|
result.actions.push(...(oktRes.actions || []))
|
|
} catch (e) {
|
|
result.actions.push({ action: 'oktopus_provision_failed', error: e.message })
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
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: [] }
|
|
const oldStatus = { permanent: 'Défectueux', diagnostic: 'En diagnostic', upgrade: 'Retourné' }[swapType] || 'Défectueux'
|
|
|
|
let oldEquip = {}
|
|
try {
|
|
const r = await erpRequest('GET', `/api/resource/Service Equipment/${encodeURIComponent(old_equipment_name)}`)
|
|
oldEquip = r.data?.data || r.data || {}
|
|
} catch { return json(res, 404, { error: 'Old equipment not found: ' + old_equipment_name }) }
|
|
|
|
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 }) }
|
|
|
|
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',
|
|
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 }) }
|
|
|
|
if ((equipment_type || oldEquip.equipment_type) === 'ONT' && (service_location || oldEquip.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 m = (loc.olt_port || '').match(/(\d+)\/(\d+)\/(\d+)/)
|
|
if (loc.olt_ip && m) {
|
|
result.olt_swap_commands = [
|
|
`# OLT: ${loc.olt_ip} — Swap ONT on ${m[0]}`,
|
|
`interface gpon ${m[1]}/${m[2]}`,
|
|
`ont delete ${m[3]} ${loc.ont_id || 0}`,
|
|
`ont add ${m[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 ${m[3]} ont ${loc.ont_id || 0}`, `quit`,
|
|
loc.vlan_internet ? `service-port auto vlan ${loc.vlan_internet} gemport 1 gpon ${m[1]}/${m[2]}/${m[3]} ont ${loc.ont_id || 0} transparent` : '',
|
|
loc.vlan_manage ? `service-port auto vlan ${loc.vlan_manage} gemport 2 gpon ${m[1]}/${m[2]}/${m[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 }) }
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
if (action === 'equipment' && method === 'GET') {
|
|
const serial = decodeURIComponent(path.replace('/provision/', '').split('/').filter(Boolean)[1] || '')
|
|
if (!serial) return json(res, 400, { error: 'Missing serial' })
|
|
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 })
|
|
}
|
|
}
|
|
|
|
module.exports = { handle }
|