Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
227 lines
8.0 KiB
JavaScript
227 lines
8.0 KiB
JavaScript
/**
|
|
* Service: Sinkronisasi ONU (OLT) dengan Data Pelanggan.
|
|
*
|
|
* Dua tugas dalam satu siklus:
|
|
* 1. Auto-isi/perbarui `mac_address` pelanggan dari caller-id sesi PPPoE aktif
|
|
* HANYA bila caller-id terverifikasi sebagai serial ONU yang benar-benar ada
|
|
* di OLT (agar MAC router/Cpe bridge tidak tersimpan salah). Nilai lama akan
|
|
* diperbarui sehingga penggantian ONU ikut terdeteksi otomatis.
|
|
* 2. Ganti nama ONU di OLT menjadi `pppoe_username` / nama pelanggan berdasarkan
|
|
* kecocokan: mac_address pelanggan, enrichment stats, atau caller-id PPPoE live.
|
|
*
|
|
* Satu kali pengambilan daftar ONU per OLT (SNMP walk) dipakai untuk kedua tugas.
|
|
*/
|
|
const { logger } = require('../config/logger');
|
|
const db = require('../config/database');
|
|
const oltService = require('./oltService');
|
|
const mikrotikService = require('./mikrotikService');
|
|
|
|
function normalizeKey(s) {
|
|
return String(s || '').replace(/[^a-zA-Z0-9]/g, '').toLowerCase();
|
|
}
|
|
|
|
/**
|
|
* Normalisasi MAC menjadi format AA:BB:CC:DD:EE:FF. Kembalikan null bila bukan 12 hex.
|
|
*/
|
|
function formatMac(raw) {
|
|
const hex = String(raw || '').replace(/[^0-9a-fA-F]/g, '').toUpperCase();
|
|
if (hex.length !== 12) return null;
|
|
return hex.match(/.{2}/g).join(':');
|
|
}
|
|
|
|
function loadCustomerContext() {
|
|
const custs = db.prepare('SELECT id, name, pppoe_username, mac_address FROM customers').all() || [];
|
|
const byId = new Map();
|
|
const byMac = new Map();
|
|
const byUser = new Map();
|
|
for (const c of custs) {
|
|
byId.set(c.id, c);
|
|
if (c.mac_address) byMac.set(normalizeKey(c.mac_address), c);
|
|
if (c.pppoe_username) byUser.set(String(c.pppoe_username).trim().toLowerCase(), c);
|
|
}
|
|
return { custs, byId, byMac, byUser };
|
|
}
|
|
|
|
/**
|
|
* Bangun peta caller-id (MAC) -> username PPPoE dari sesi aktif.
|
|
*/
|
|
function buildMacToUser(sessions) {
|
|
const map = new Map();
|
|
for (const [username, s] of sessions.entries()) {
|
|
const k = normalizeKey(s.callerId);
|
|
if (k && !map.has(k)) map.set(k, username);
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Isi/perbarui mac_address pelanggan dari caller-id PPPoE, hanya untuk MAC ONU
|
|
* yang terverifikasi ada di daftar serial OLT. Tampilkan/lewati konflik.
|
|
*/
|
|
function refreshCustomerMacs(sessions, byUser, byMac, onuSerialSet, dryRun) {
|
|
const stmt = db.prepare('UPDATE customers SET mac_address = ? WHERE id = ?');
|
|
const summary = { total: sessions.size, filled: 0, refreshed: 0, skippedNonOnu: 0, noCustomer: 0, conflict: 0, errors: [] };
|
|
|
|
for (const [username, s] of sessions.entries()) {
|
|
const cust = byUser.get(username);
|
|
if (!cust) {
|
|
summary.noCustomer++;
|
|
continue;
|
|
}
|
|
const mac = formatMac(s.callerId);
|
|
if (!mac) {
|
|
summary.skippedNonOnu++;
|
|
continue;
|
|
}
|
|
const norm = normalizeKey(mac);
|
|
if (!onuSerialSet.has(norm)) {
|
|
summary.skippedNonOnu++;
|
|
continue;
|
|
}
|
|
const owner = byMac.get(norm);
|
|
if (owner && owner.id !== cust.id) {
|
|
summary.conflict++;
|
|
logger.warn(`[SyncMAC] MAC ONU ${mac} sudah terpakai pelanggan lain (${owner.name}); lewati ${cust.name}`);
|
|
continue;
|
|
}
|
|
const current = String(cust.mac_address || '').trim();
|
|
if (current && normalizeKey(current) === norm) continue;
|
|
try {
|
|
if (!dryRun) stmt.run(mac, cust.id);
|
|
if (current) summary.refreshed++;
|
|
else summary.filled++;
|
|
if (current) byMac.delete(normalizeKey(current));
|
|
byMac.set(norm, cust);
|
|
logger.info(`[SyncMAC] ${dryRun ? '[DRY-RUN] ' : ''}${current ? 'update' : 'isi'} mac_address ${cust.name} => ${mac}`);
|
|
} catch (err) {
|
|
summary.errors.push({ customer: cust.name, error: err.message });
|
|
logger.error(`[SyncMAC] Gagal ${current ? 'update' : 'isi'} mac_address ${cust.name}: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
return summary;
|
|
}
|
|
|
|
/**
|
|
* Tentukan target nama untuk satu ONU.
|
|
* @returns {{customer: object|null, target: string|null, via: string|null}}
|
|
*/
|
|
function resolveOnuTarget(onu, byId, byMac, byUser, macToUser) {
|
|
const snNorm = normalizeKey(onu.sn);
|
|
let customer = onu.customer_id ? byId.get(onu.customer_id) : null;
|
|
let via = 'mac_map';
|
|
if (!customer) {
|
|
if (snNorm && byMac.has(snNorm)) {
|
|
customer = byMac.get(snNorm);
|
|
via = 'mac_map';
|
|
} else if (snNorm && macToUser.has(snNorm)) {
|
|
const uname = macToUser.get(snNorm);
|
|
customer = byUser.get(uname) || null;
|
|
via = customer ? 'pppoe_live' : null;
|
|
}
|
|
}
|
|
if (!customer) return { customer: null, target: null, via: null };
|
|
const target = (customer.pppoe_username && String(customer.pppoe_username).trim())
|
|
? String(customer.pppoe_username).trim()
|
|
: String(customer.name || '').trim();
|
|
return { customer, target, via };
|
|
}
|
|
|
|
async function syncOnuNamesFromStats(olt, onus, ctx, macToUser, dryRun) {
|
|
const summary = { oltId: olt.id, oltName: olt.name, total: onus.length, renamed: 0, failed: 0, same: 0, unmatch: 0, errors: [] };
|
|
for (const onu of onus) {
|
|
const { customer, target, via } = resolveOnuTarget(onu, ctx.byId, ctx.byMac, ctx.byUser, macToUser);
|
|
if (!customer || !target) {
|
|
summary.unmatch++;
|
|
continue;
|
|
}
|
|
const writable = target.slice(0, 30);
|
|
if (String(onu.name || '').trim() === writable) {
|
|
summary.same++;
|
|
continue;
|
|
}
|
|
try {
|
|
if (!dryRun) await oltService.renameOnu(olt.id, onu.index, target);
|
|
summary.renamed++;
|
|
logger.info(`[SyncONU] ${dryRun ? '[DRY-RUN] akan rename' : 'rename'} ${onu.id} (${onu.sn}) => ${target} (${via})`);
|
|
} catch (err) {
|
|
summary.failed++;
|
|
summary.errors.push({ onu: onu.id, sn: onu.sn, target, error: err.message });
|
|
logger.error(`[SyncONU] Gagal rename ${onu.id} => ${target}: ${err.message}`);
|
|
}
|
|
}
|
|
return summary;
|
|
}
|
|
|
|
/**
|
|
* Orkestrator sinkronisasi: mengambil daftar ONU sekali per OLT, lalu
|
|
* (1) auto-isi/perbarui mac_address dan (2) rename nama ONU.
|
|
* @param {{dryRun?: boolean, oltId?: number|string, skipFill?: boolean, skipNames?: boolean}} opts
|
|
*/
|
|
async function runSyncOnu(opts = {}) {
|
|
const dryRun = !!opts.dryRun;
|
|
const onlyOltId = opts.oltId != null ? String(opts.oltId) : null;
|
|
|
|
let olts = (oltService.getAllOlts() || []).filter((o) => o.is_active === 1 || o.is_active === true);
|
|
if (onlyOltId) olts = olts.filter((o) => String(o.id) === onlyOltId);
|
|
|
|
// 1) Ambil daftar ONU (satu walk per OLT) + kumpulkan serial ONU
|
|
const onusByOlt = [];
|
|
const onuSerialSet = new Map();
|
|
for (const olt of olts) {
|
|
try {
|
|
const stats = await oltService.getOltStats(olt.id, true);
|
|
const onus = (stats && stats.onus) || [];
|
|
onusByOlt.push({ olt, onus });
|
|
for (const onu of onus) {
|
|
const k = normalizeKey(onu.sn);
|
|
if (k && !onuSerialSet.has(k)) onuSerialSet.set(k, { oltId: olt.id, oltName: olt.name, onuId: onu.id });
|
|
}
|
|
} catch (err) {
|
|
logger.error(`[SyncONU] Gagal ambil daftar ONU ${olt.name}: ${err.message}`);
|
|
onusByOlt.push({ olt, onus: [], error: err.message });
|
|
}
|
|
}
|
|
|
|
// 2) Sesi PPPoE aktif
|
|
let sessions = new Map();
|
|
try {
|
|
sessions = await mikrotikService.getActivePppoeSessionsMap();
|
|
} catch (e) {
|
|
logger.error(`[SyncMAC] Gagal ambil sesi PPPoE: ${e.message}`);
|
|
}
|
|
|
|
// 3) Auto-isi/perbarui mac_address (hanya MAC ONU terverifikasi)
|
|
let mac;
|
|
if (opts.skipFill) {
|
|
mac = { skipped: true, total: sessions.size };
|
|
} else {
|
|
const ctx0 = loadCustomerContext();
|
|
mac = refreshCustomerMacs(sessions, ctx0.byUser, ctx0.byMac, onuSerialSet, dryRun);
|
|
}
|
|
|
|
// 4) Sinkronkan nama ONU (pakai data pelanggan terbaru)
|
|
let summaries = [];
|
|
if (!opts.skipNames) {
|
|
const macToUser = buildMacToUser(sessions);
|
|
const ctx1 = loadCustomerContext();
|
|
for (const { olt, onus } of onusByOlt) {
|
|
summaries.push(await syncOnuNamesFromStats(olt, onus, ctx1, macToUser, dryRun));
|
|
}
|
|
}
|
|
|
|
return { mac, olts: summaries };
|
|
}
|
|
|
|
/**
|
|
* Sinkronkan nama ONU untuk satu OLT (kompatibilitas).
|
|
*/
|
|
async function syncOnuCustomerNames(oltId, opts = {}) {
|
|
const res = await runSyncOnu({ dryRun: !!opts.dryRun, oltId, skipFill: true });
|
|
const s = (res.olts && res.olts[0]) || null;
|
|
if (!s) throw new Error('OLT tidak ditemukan');
|
|
return s;
|
|
}
|
|
|
|
module.exports = { runSyncOnu, syncOnuCustomerNames, formatMac };
|