Compare commits
7 Commits
v0.5.2
...
2b35ee3f99
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b35ee3f99 | ||
|
|
adc3437bd5 | ||
|
|
4003149046 | ||
|
|
1c40e6a0e8 | ||
|
|
95fa69e968 | ||
|
|
d24cabf818 | ||
|
|
2a29739dc3 |
@@ -1,4 +1,4 @@
|
||||
> 🪞 **Mirror.** Repo asli & pengembangan ada di **Gitea**: https://gitea.oxpoe.qzz.io/oxpoe/open-isp
|
||||
> 🪞 **Mirror.** Repo asli, pengembangan & **rilis/unduhan** ada di **Gitea**: https://gitea.oxpoe.qzz.io/oxpoe/open-isp/releases · GitHub hanya cermin kode.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/assets/banner.png" alt="OPEN-ISP" width="100%">
|
||||
|
||||
@@ -1107,6 +1107,18 @@ try {
|
||||
for (const [n, def] of _add) { if (!_have.has(n)) db.exec('ALTER TABLE packages ADD COLUMN ' + n + ' ' + def); }
|
||||
} catch (e) { console.error('Failed to migrate packages ip columns:', e); }
|
||||
|
||||
// Safe migration: kolom tambahan inventaris (harga/supplier + log penerima & sisa stok)
|
||||
try {
|
||||
const stockCols = db.prepare("PRAGMA table_info(inventory_stock)").all();
|
||||
if (!stockCols.find(c => c.name === 'price')) db.exec("ALTER TABLE inventory_stock ADD COLUMN price INTEGER DEFAULT 0");
|
||||
if (!stockCols.find(c => c.name === 'supplier')) db.exec("ALTER TABLE inventory_stock ADD COLUMN supplier TEXT DEFAULT ''");
|
||||
|
||||
const logCols = db.prepare("PRAGMA table_info(inventory_logs)").all();
|
||||
if (!logCols.find(c => c.name === 'remaining_stock')) db.exec("ALTER TABLE inventory_logs ADD COLUMN remaining_stock INTEGER DEFAULT 0");
|
||||
if (!logCols.find(c => c.name === 'recipient')) db.exec("ALTER TABLE inventory_logs ADD COLUMN recipient TEXT DEFAULT ''");
|
||||
if (!logCols.find(c => c.name === 'reference_no')) db.exec("ALTER TABLE inventory_logs ADD COLUMN reference_no TEXT DEFAULT ''");
|
||||
} catch (e) { console.error('Failed to migrate inventory columns:', e); }
|
||||
|
||||
module.exports = db;
|
||||
module.exports.getAppSetting = getAppSetting;
|
||||
module.exports.saveAppSetting = saveAppSetting;
|
||||
|
||||
@@ -285,6 +285,14 @@ function ensureDefaultSettings() {
|
||||
|
||||
// Default settings yang wajib ada
|
||||
const defaultSettings = {
|
||||
// AI (OpenAI-compatible) & self-healing
|
||||
ai_provider: 'deepseek',
|
||||
deepseek_model: 'deepseek-chat',
|
||||
openai_model: 'gpt-4o-mini',
|
||||
groq_model: 'llama-3.3-70b-versatile',
|
||||
custom_ai_base_url: '',
|
||||
custom_ai_model: 'llama3',
|
||||
ai_self_healing_enabled: false,
|
||||
// RADIUS Server settings (ditambahkan untuk update dari Gitea)
|
||||
radius_enabled: '0',
|
||||
radius_secret: 'secret123',
|
||||
|
||||
BIN
docs/screenshots/01-landing.png
Normal file
|
After Width: | Height: | Size: 414 KiB |
BIN
docs/screenshots/02-admin-login.png
Normal file
|
After Width: | Height: | Size: 225 KiB |
BIN
docs/screenshots/03-admin-dashboard.png
Normal file
|
After Width: | Height: | Size: 213 KiB |
BIN
docs/screenshots/04-admin-customers.png
Normal file
|
After Width: | Height: | Size: 216 KiB |
BIN
docs/screenshots/05-admin-inventory.png
Normal file
|
After Width: | Height: | Size: 189 KiB |
BIN
docs/screenshots/06-inventory-stok-keluar.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
docs/screenshots/07-admin-settings-ai.png
Normal file
|
After Width: | Height: | Size: 158 KiB |
BIN
docs/screenshots/08-customer-login.png
Normal file
|
After Width: | Height: | Size: 321 KiB |
BIN
docs/screenshots/09-customer-dashboard.png
Normal file
|
After Width: | Height: | Size: 181 KiB |
BIN
docs/screenshots/10-mobile-customer-login.png
Normal file
|
After Width: | Height: | Size: 318 KiB |
20
docs/screenshots/README.md
Normal file
@@ -0,0 +1,20 @@
|
||||
# Screenshot Aplikasi (v0.5.2)
|
||||
|
||||
Diambil dari instance uji lokal (`http://localhost:3001`) dengan data demo
|
||||
(`node scripts/seed-demo.js`), viewport 1440×900 (mobile 390×844).
|
||||
|
||||
| File | Halaman |
|
||||
|---|---|
|
||||
| `01-landing.png` | Landing page publik |
|
||||
| `02-admin-login.png` | Halaman login admin |
|
||||
| `03-admin-dashboard.png` | Dashboard admin (ringkasan pelanggan, billing, ONU) |
|
||||
| `04-admin-customers.png` | Daftar pelanggan |
|
||||
| `05-admin-inventory.png` | Inventaris stok |
|
||||
| `06-inventory-stok-keluar.png` | Modal Stok Keluar (FIFO / serial number) |
|
||||
| `07-admin-settings-ai.png` | Pengaturan → AI & Self-Healing (key disamarkan) |
|
||||
| `08-customer-login.png` | Login pelanggan + tab Status mengikuti setting `uptime_url` |
|
||||
| `09-customer-dashboard.png` | Dashboard pelanggan |
|
||||
| `10-mobile-customer-login.png` | Login pelanggan (tampilan mobile) |
|
||||
|
||||
Catatan: field API key di halaman Pengaturan memakai `type="password"`
|
||||
sehingga tidak tampil di screenshot maupun saat layar dibagikan.
|
||||
@@ -56,6 +56,8 @@ const promoUpload = multer({
|
||||
const backupSvc = require('../services/backupService');
|
||||
const monitoringSvc = require('../services/monitoringService');
|
||||
const inventorySvc = require('../services/inventoryService');
|
||||
const securityWatchdog = require('../services/securityWatchdogService');
|
||||
const sessionLimit = require('../services/sessionLimitService');
|
||||
const auditSvc = require('../services/auditTrailService');
|
||||
const whatsappService = require('../services/whatsappService');
|
||||
const { parseMikhmonOnLogin } = require('../utils/mikhmonParser');
|
||||
@@ -554,17 +556,38 @@ router.get('/login', (req, res) => {
|
||||
res.render('admin/login', { title: 'Admin Login', company: company(), error: null });
|
||||
});
|
||||
|
||||
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => {
|
||||
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => {
|
||||
const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1';
|
||||
if (securityWatchdog.isIpBlocked(ip)) {
|
||||
return res.render('admin/login', {
|
||||
title: 'Admin Login',
|
||||
company: company(),
|
||||
error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.'
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
if (username === getSetting('admin_username', 'admin') && password === getSetting('admin_password', 'demo-isp123')) {
|
||||
await securityWatchdog.recordAppLoginAttempt({ type: 'admin', username, ip, success: true });
|
||||
await sessionLimit.rotateSession(req);
|
||||
req.session.isAdmin = true;
|
||||
req.session.adminUser = username;
|
||||
sessionLimit.registerLoginSession(req, {
|
||||
role: 'admin', accountId: username, limit: 2,
|
||||
ip, userAgent: req.get('user-agent')
|
||||
});
|
||||
return res.redirect('/admin');
|
||||
}
|
||||
|
||||
// Check Cashier
|
||||
const cashier = adminSvc.authenticateCashier(username, password);
|
||||
if (cashier) {
|
||||
await securityWatchdog.recordAppLoginAttempt({ type: 'cashier', username, ip, success: true });
|
||||
await sessionLimit.rotateSession(req);
|
||||
sessionLimit.registerLoginSession(req, {
|
||||
role: 'cashier', accountId: cashier.id, limit: 2,
|
||||
ip, userAgent: req.get('user-agent')
|
||||
});
|
||||
req.session.isCashier = true;
|
||||
req.session.cashierId = cashier.id;
|
||||
req.session.cashierName = cashier.name;
|
||||
@@ -572,10 +595,21 @@ router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }),
|
||||
return res.redirect('/admin');
|
||||
}
|
||||
|
||||
await securityWatchdog.recordAppLoginAttempt({
|
||||
type: 'admin',
|
||||
username: username || '',
|
||||
ip,
|
||||
success: false,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
res.render('admin/login', { title: 'Admin Login', company: company(), error: 'Username atau password salah' });
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => { req.session.destroy(); res.redirect('/admin/login'); });
|
||||
router.get('/logout', (req, res) => {
|
||||
sessionLimit.removeLoginSession(req);
|
||||
req.session.destroy();
|
||||
res.redirect('/admin/login');
|
||||
});
|
||||
|
||||
// ─── OLT MANAGEMENT ────────────────────────────────────────────────────────
|
||||
router.get('/olts', requireAdminSession, async (req, res) => {
|
||||
@@ -4741,6 +4775,12 @@ router.post('/settings', requireAdminSession, express.urlencoded({ extended: tru
|
||||
if (newSettings.voucher_enabled === 'true') newSettings.voucher_enabled = true;
|
||||
else if (newSettings.voucher_enabled === 'false') newSettings.voucher_enabled = false;
|
||||
|
||||
newSettings.ai_provider = String(newSettings.ai_provider || 'deepseek').trim().toLowerCase();
|
||||
newSettings.ai_self_healing_enabled = newSettings.ai_self_healing_enabled === 'true' || newSettings.ai_self_healing_enabled === 'on';
|
||||
for (const k of ['deepseek_api_key','openai_api_key','groq_api_key','openrouter_api_key','custom_ai_api_key','custom_ai_base_url','custom_ai_model','deepseek_model','openai_model','groq_model','openrouter_model']) {
|
||||
if (newSettings[k] !== undefined) newSettings[k] = String(newSettings[k]).trim();
|
||||
}
|
||||
|
||||
if (newSettings.qris_static_enabled === 'true') newSettings.qris_static_enabled = true;
|
||||
else if (newSettings.qris_static_enabled === 'false') newSettings.qris_static_enabled = false;
|
||||
|
||||
@@ -5096,7 +5136,8 @@ router.post('/backup/cleanup', requireAdminSession, express.urlencoded({ extende
|
||||
router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventory'), (req, res) => {
|
||||
const items = inventorySvc.getAllItems(req.query.q);
|
||||
const categories = inventorySvc.getAllCategories();
|
||||
const logs = inventorySvc.getInventoryLogs(100);
|
||||
const logs = inventorySvc.getInventoryLogs({ limit: 100 });
|
||||
const stats = inventorySvc.getInventoryStats();
|
||||
|
||||
res.render('admin/inventory', {
|
||||
title: 'Manajemen Inventaris',
|
||||
@@ -5105,7 +5146,8 @@ router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventor
|
||||
msg: flashMsg(req),
|
||||
items,
|
||||
categories,
|
||||
logs
|
||||
logs,
|
||||
stats
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5159,6 +5201,101 @@ router.post('/inventory/stock/add', requireAdminSession, express.urlencoded({ ex
|
||||
res.redirect('/admin/inventory');
|
||||
});
|
||||
|
||||
router.post('/inventory/stock/out', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
const result = inventorySvc.issueStock(req.body, req.session.adminUser || 'Admin');
|
||||
req.session._msg = { type: 'success', text: `Stok keluar dicatat. Sisa tersedia: ${result.remainingStock}.` };
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
|
||||
}
|
||||
res.redirect('/admin/inventory');
|
||||
});
|
||||
|
||||
router.post('/inventory/stock/return', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
inventorySvc.returnStock(req.body, req.session.adminUser || 'Admin');
|
||||
req.session._msg = { type: 'success', text: 'Retur barang berhasil dicatat.' };
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
|
||||
}
|
||||
res.redirect('/admin/inventory');
|
||||
});
|
||||
|
||||
router.post('/inventory/stock/adjust', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
const result = inventorySvc.adjustStock(req.body, req.session.adminUser || 'Admin');
|
||||
req.session._msg = { type: 'success', text: result.message || `Opname disimpan. Selisih: ${result.diff}, stok fisik: ${result.remainingStock}.` };
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
|
||||
}
|
||||
res.redirect('/admin/inventory');
|
||||
});
|
||||
|
||||
router.get('/api/inventory/item/:id', requireAdminSession, (req, res) => {
|
||||
try {
|
||||
const detail = inventorySvc.getItemDetail(req.params.id);
|
||||
if (!detail) return res.status(404).json({ success: false, message: 'Barang tidak ditemukan' });
|
||||
res.json({ success: true, ...detail });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/inventory/serials/:itemId', requireAdminSession, (req, res) => {
|
||||
try {
|
||||
res.json({ success: true, serials: inventorySvc.getAvailableSerialNumbers(req.params.itemId) });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/self-heal/diagnose', requireAdminSession, restrictToAdmin, async (req, res) => {
|
||||
try {
|
||||
const svc = require('../services/selfHealingService');
|
||||
const result = await svc.diagnose();
|
||||
res.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/self-heal/run', requireAdminSession, restrictToAdmin, async (req, res) => {
|
||||
try {
|
||||
const svc = require('../services/selfHealingService');
|
||||
const result = await svc.autoRepair(req.session.adminUser || 'Web Admin');
|
||||
res.json({ success: true, ...result });
|
||||
} catch (e) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/api/soundbox/poll', requireAdminSession, (req, res) => {
|
||||
try {
|
||||
const soundboxSvc = require('../services/soundboxService');
|
||||
const events = soundboxSvc.getLatestEvents(Number(req.query.afterId || 0), 15);
|
||||
res.json({ success: true, events });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message, events: [] });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/soundbox/test', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
const soundboxSvc = require('../services/soundboxService');
|
||||
const amount = Number(req.body?.amount || 50000);
|
||||
const id = soundboxSvc.queueSoundboxEvent({
|
||||
type: 'test',
|
||||
title: 'Uji Suara Kasir',
|
||||
speechText: req.body?.speechText || ('Uji coba speaker kasir berhasil. Pembayaran sebesar ' + soundboxSvc.formatRupiahSpoken(amount) + ' diterima.'),
|
||||
amount,
|
||||
customerName: 'Admin Soundbox'
|
||||
});
|
||||
res.json({ success: true, eventId: id });
|
||||
} catch (err) {
|
||||
res.status(500).json({ success: false, message: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => {
|
||||
const filters = {
|
||||
action: req.query.action || null,
|
||||
@@ -7790,6 +7927,16 @@ router.post('/payroll/slip/:id/deduction', requireAdmin, express.json(), (req, r
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/payroll/slip/:id/update', requireAdmin, express.urlencoded({ extended: true }), (req, res) => {
|
||||
try {
|
||||
payrollSvc.updateSlip(req.params.id, req.body);
|
||||
req.session._msg = { type: 'success', text: 'Rincian slip gaji berhasil diperbarui.' };
|
||||
} catch (e) {
|
||||
req.session._msg = { type: 'error', text: 'Gagal memperbarui slip: ' + e.message };
|
||||
}
|
||||
res.redirect('back');
|
||||
});
|
||||
|
||||
router.post('/payroll/slip/:id/approve', requireAdmin, (req, res) => {
|
||||
try {
|
||||
payrollSvc.approveSlip(req.params.id);
|
||||
|
||||
@@ -5,6 +5,8 @@ const { logger } = require('../config/logger');
|
||||
const db = require('../config/database');
|
||||
const billingSvc = require('../services/billingService');
|
||||
const customerSvc = require('../services/customerService');
|
||||
const securityWatchdog = require('../services/securityWatchdogService');
|
||||
const sessionLimit = require('../services/sessionLimitService');
|
||||
const adminSvc = require('../services/adminService');
|
||||
const attendanceSvc = require('../services/attendanceService');
|
||||
const pdfSvc = require('../services/pdfInvoiceService');
|
||||
@@ -49,22 +51,45 @@ router.get('/login', (req, res) => {
|
||||
res.render('collector/login', { title: 'Login Kolektor', company: company(), error: null });
|
||||
});
|
||||
|
||||
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => {
|
||||
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => {
|
||||
const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1';
|
||||
if (securityWatchdog.isIpBlocked(ip)) {
|
||||
return res.render('collector/login', {
|
||||
title: 'Login Kolektor',
|
||||
company: company(),
|
||||
error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.'
|
||||
});
|
||||
}
|
||||
|
||||
const username = String(req.body.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const collector = adminSvc.authenticateCollector(username, password);
|
||||
if (collector) {
|
||||
await securityWatchdog.recordAppLoginAttempt({ type: 'collector', username, ip, success: true });
|
||||
await sessionLimit.rotateSession(req);
|
||||
req.session.isCollector = true;
|
||||
sessionLimit.registerLoginSession(req, {
|
||||
role: 'collector', accountId: collector.id, limit: 2,
|
||||
ip, userAgent: req.get('user-agent')
|
||||
});
|
||||
req.session.collectorId = collector.id;
|
||||
req.session.collectorName = collector.name;
|
||||
req.session.collectorUsername = collector.username;
|
||||
req.session.collectorArea = collector.area || '';
|
||||
return res.redirect('/collector');
|
||||
}
|
||||
await securityWatchdog.recordAppLoginAttempt({
|
||||
type: 'collector',
|
||||
username,
|
||||
ip,
|
||||
success: false,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
return res.render('collector/login', { title: 'Login Kolektor', company: company(), error: 'Username atau password salah!' });
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
sessionLimit.removeLoginSession(req);
|
||||
req.session.destroy();
|
||||
res.redirect('/collector/login');
|
||||
});
|
||||
@@ -177,16 +202,10 @@ router.get('/', requireCollectorSession, (req, res) => {
|
||||
const todayDay = now.getDate();
|
||||
|
||||
const collectorId = Number(req.session.collectorId || 0);
|
||||
const collectorObj = db.prepare('SELECT area FROM collectors WHERE id = ?').get(collectorId);
|
||||
const collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim();
|
||||
req.session.collectorArea = collectorArea;
|
||||
const collectorObj = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(collectorId);
|
||||
req.session.collectorArea = String(collectorObj?.area || req.session.collectorArea || '').trim();
|
||||
|
||||
let collectorWhere = '(c.collector_id = ? OR c.collector_id IS NULL)';
|
||||
let collectorParams = [collectorId];
|
||||
if (collectorArea) {
|
||||
collectorWhere = '(c.collector_id = ? OR ((c.collector_id IS NULL OR c.collector_id = 0) AND LOWER(TRIM(c.area)) = LOWER(TRIM(?))))';
|
||||
collectorParams = [collectorId, collectorArea];
|
||||
}
|
||||
const { where: collectorWhere, params: collectorParams } = customerSvc.getCollectorCustomerWhere(collectorObj, 'c');
|
||||
|
||||
let q = `
|
||||
SELECT c.id as customer_id,
|
||||
@@ -351,6 +370,14 @@ router.post('/payment-request', requireCollectorSession, express.urlencoded({ ex
|
||||
if (!inv) throw new Error('Tagihan tidak ditemukan');
|
||||
if (String(inv.status || '').toLowerCase() === 'paid') throw new Error('Tagihan sudah lunas');
|
||||
|
||||
{
|
||||
const collectorIdCheck = Number(req.session.collectorId || 0);
|
||||
const invCust = customerSvc.getCustomerById(inv.customer_id);
|
||||
if (!customerSvc.canCollectorAccessCustomer(collectorIdCheck, invCust)) {
|
||||
throw new Error('Tagihan ini di luar wilayah/tugas Anda');
|
||||
}
|
||||
}
|
||||
|
||||
const existingPending = db.prepare(`
|
||||
SELECT id FROM collector_payment_requests
|
||||
WHERE invoice_id = ? AND status = 'pending'
|
||||
@@ -472,6 +499,9 @@ router.get('/invoice/:id/print-thermal', requireCollectorSession, (req, res) =>
|
||||
|
||||
const customer = customerSvc.getCustomerById(invoice.customer_id);
|
||||
if (!customer) return res.status(404).send('Data pelanggan tidak ditemukan');
|
||||
if (!customerSvc.canCollectorAccessCustomer(Number(req.session.collectorId || 0), customer)) {
|
||||
return res.status(403).send('Invoice di luar wilayah tugas Anda');
|
||||
}
|
||||
const settings = getSettings();
|
||||
|
||||
res.render('collector/print_thermal', {
|
||||
@@ -498,6 +528,9 @@ router.post('/invoice/:id/send-pdf-wa', requireCollectorSession, async (req, res
|
||||
|
||||
const customer = customerSvc.getCustomerById(inv.customer_id);
|
||||
if (!customer || !customer.phone) return res.json({ success: false, message: 'Nomor HP pelanggan tidak valid' });
|
||||
if (!customerSvc.canCollectorAccessCustomer(Number(req.session.collectorId || 0), customer)) {
|
||||
return res.json({ success: false, message: 'Invoice di luar wilayah tugas Anda' });
|
||||
}
|
||||
|
||||
const settings = getSettings();
|
||||
const pdfBuffer = await pdfSvc.generateInvoicePdfBuffer(inv, customer, settings);
|
||||
|
||||
@@ -740,6 +740,13 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
|
||||
const curMonth = now.getMonth() + 1;
|
||||
const curYear = now.getFullYear();
|
||||
|
||||
const isFullAdmin = req.collector?.role === 'admin' || req.collector?.role === 'root';
|
||||
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
|
||||
const collector = collectorId
|
||||
? db.prepare('SELECT id, name, area, auto_approve FROM collectors WHERE id = ?').get(collectorId)
|
||||
: null;
|
||||
const colFilter = (!isFullAdmin && collector) ? customerSvc.getCollectorCustomerWhere(collector, 'c') : null;
|
||||
|
||||
let q = `
|
||||
SELECT c.id, c.name, c.phone, c.address, c.status, c.area, c.isolate_day, c.pppoe_username,
|
||||
p.name as package_name, p.price as package_price,
|
||||
@@ -752,6 +759,11 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
|
||||
`;
|
||||
const params = [curMonth, curYear];
|
||||
|
||||
if (colFilter) {
|
||||
q += ` AND ${colFilter.where}`;
|
||||
params.push(...colFilter.params);
|
||||
}
|
||||
|
||||
if (filterStatus === 'unpaid') {
|
||||
q += ` AND (i.status = 'unpaid' OR i.status IS NULL)`;
|
||||
} else if (filterStatus === 'today') {
|
||||
@@ -770,6 +782,7 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
|
||||
q += ` ORDER BY (CASE WHEN c.status = 'suspended' THEN 0 WHEN i.status = 'unpaid' THEN 1 ELSE 2 END), c.name ASC LIMIT 150`;
|
||||
const list = db.prepare(q).all(...params) || [];
|
||||
|
||||
const statsWhere = colFilter ? ` WHERE ${colFilter.where}` : '';
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
SUM(CASE WHEN (i.status = 'unpaid' OR i.status IS NULL) THEN 1 ELSE 0 END) as unpaid_count,
|
||||
@@ -779,7 +792,8 @@ router.get('/app/collector/dashboard', requireCollectorApiAuth, (req, res) => {
|
||||
FROM customers c
|
||||
LEFT JOIN packages p ON p.id = c.package_id
|
||||
LEFT JOIN invoices i ON i.customer_id = c.id AND i.period_month = ? AND i.period_year = ?
|
||||
`).get(todayDay, curMonth, curYear) || {};
|
||||
${statsWhere}
|
||||
`).get(todayDay, curMonth, curYear, ...(colFilter ? colFilter.params : [])) || {};
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
@@ -819,7 +833,17 @@ router.post('/app/collector/pay-bill', requireCollectorApiAuth, async (req, res)
|
||||
if (!inv) return res.status(404).json({ success: false, message: 'Tagihan tidak ditemukan' });
|
||||
|
||||
const collectorId = Number(req.collector?.collectorId || req.collector?.id || 0);
|
||||
const collector = collectorId ? db.prepare('SELECT id, name, auto_approve FROM collectors WHERE id = ?').get(collectorId) : null;
|
||||
const collector = collectorId ? db.prepare('SELECT id, name, area, auto_approve FROM collectors WHERE id = ?').get(collectorId) : null;
|
||||
|
||||
const isFullAdmin = req.collector?.role === 'admin' || req.collector?.role === 'root';
|
||||
if (!isFullAdmin && collector) {
|
||||
const cust = customerSvc.getCustomerById(inv.customer_id);
|
||||
if (!cust) return res.status(404).json({ success: false, message: 'Pelanggan tidak ditemukan' });
|
||||
if (!customerSvc.canCollectorAccessCustomer(collector, cust)) {
|
||||
return res.status(403).json({ success: false, message: 'Akses ditolak: pelanggan di luar wilayah/tugas Anda' });
|
||||
}
|
||||
}
|
||||
|
||||
const collectorName = collector?.name || req.collector?.name || 'Kolektor Lapangan';
|
||||
const isAutoApprove = collector && collector.auto_approve === 1;
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
const express = require('express');
|
||||
const sessionLimit = require('../services/sessionLimitService');
|
||||
const router = express.Router();
|
||||
const customerDevice = require('../services/customerDeviceService');
|
||||
const { getSettingsWithCache, getNowLocal, getCurrentTimeInfo, getNowLocalISO, formatDateLocal, formatTimeLocal } = require('../config/settingsManager');
|
||||
@@ -1791,6 +1792,14 @@ router.post('/login', loginRateLimiter, async (req, res) => {
|
||||
|
||||
// --- DIRECT LOGIN ---
|
||||
logger.info('[Login] Login direct berhasil.');
|
||||
await sessionLimit.rotateSession(req);
|
||||
sessionLimit.registerLoginSession(req, {
|
||||
role: 'customer',
|
||||
accountId: customer ? customer.id : String(customerPhone || '').replace(/\D/g, ''),
|
||||
limit: 3,
|
||||
ip: req.ip || req.connection?.remoteAddress || '',
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
req.session.phone = customerPhone; // Nomor telepon untuk findCustomerByAny()
|
||||
req.session.pppoe_username = pppoeUsername; // PPPoE username untuk GenieACS & MikroTik
|
||||
if (customer && customer.status === 'suspended') {
|
||||
@@ -1805,7 +1814,7 @@ router.get('/login-otp', (req, res) => {
|
||||
res.render('login_otp', { error: null, settings, phone: req.session.pending_login.phone });
|
||||
});
|
||||
|
||||
router.post('/login-otp', loginRateLimiter, (req, res) => {
|
||||
router.post('/login-otp', loginRateLimiter, async (req, res) => {
|
||||
const { otp } = req.body;
|
||||
const settings = getSettingsWithCache();
|
||||
const pending = req.session.pending_login;
|
||||
@@ -1824,6 +1833,15 @@ router.post('/login-otp', loginRateLimiter, (req, res) => {
|
||||
|
||||
if (otp === pending.otp) {
|
||||
logger.info('[Login] OTP berhasil diverifikasi.');
|
||||
await sessionLimit.rotateSession(req);
|
||||
const custForLimit = customerSvc.findCustomerByAny(pending.phone);
|
||||
sessionLimit.registerLoginSession(req, {
|
||||
role: 'customer',
|
||||
accountId: custForLimit ? custForLimit.id : String(pending.phone || '').replace(/\D/g, ''),
|
||||
limit: 3,
|
||||
ip: req.ip || req.connection?.remoteAddress || '',
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
req.session.phone = pending.phone; // Nomor telepon customer
|
||||
req.session.pppoe_username = pending.pppoeUsername; // PPPoE username untuk GenieACS & MikroTik
|
||||
delete req.session.pending_login;
|
||||
@@ -2591,6 +2609,7 @@ router.post('/change-tag', async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/logout', (req, res) => {
|
||||
sessionLimit.removeLoginSession(req);
|
||||
req.session.destroy(() => {
|
||||
res.redirect('/customer/login');
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const techSvc = require('../services/techService');
|
||||
const securityWatchdog = require('../services/securityWatchdogService');
|
||||
const sessionLimit = require('../services/sessionLimitService');
|
||||
const customerSvc = require('../services/customerService');
|
||||
const odpSvc = require('../services/odpService');
|
||||
const { getSetting, getNowLocal, getCurrentDateInTimezone, getNowLocalISO, formatDateLocal, getSettings, formatTimeLocal, parseDateInTimezone } = require('../config/settingsManager');
|
||||
@@ -140,19 +142,42 @@ router.get('/login', (req, res) => {
|
||||
res.render('tech/login', { title: 'Teknisi Login', company: company(), error: null });
|
||||
});
|
||||
|
||||
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), (req, res) => {
|
||||
router.post('/login', loginRateLimiter, express.urlencoded({ extended: true }), async (req, res) => {
|
||||
const ip = req.ip || req.connection?.remoteAddress || '127.0.0.1';
|
||||
if (securityWatchdog.isIpBlocked(ip)) {
|
||||
return res.render('tech/login', {
|
||||
title: 'Teknisi Login',
|
||||
company: company(),
|
||||
error: 'Akses login diblokir sementara karena terlalu banyak percobaan gagal. Coba lagi nanti.'
|
||||
});
|
||||
}
|
||||
|
||||
const { username, password } = req.body;
|
||||
const tech = techSvc.authenticate(username, password);
|
||||
if (tech) {
|
||||
await securityWatchdog.recordAppLoginAttempt({ type: 'technician', username, ip, success: true });
|
||||
await sessionLimit.rotateSession(req);
|
||||
req.session.isTechnician = true;
|
||||
sessionLimit.registerLoginSession(req, {
|
||||
role: 'technician', accountId: tech.id, limit: 2,
|
||||
ip, userAgent: req.get('user-agent')
|
||||
});
|
||||
req.session.techId = tech.id;
|
||||
req.session.techName = tech.name;
|
||||
return res.redirect('/tech');
|
||||
}
|
||||
await securityWatchdog.recordAppLoginAttempt({
|
||||
type: 'technician',
|
||||
username: username || '',
|
||||
ip,
|
||||
success: false,
|
||||
userAgent: req.get('user-agent')
|
||||
});
|
||||
res.render('tech/login', { title: 'Teknisi Login', company: company(), error: 'Username atau password salah!' });
|
||||
});
|
||||
|
||||
router.get('/logout', (req, res) => {
|
||||
sessionLimit.removeLoginSession(req);
|
||||
req.session.destroy();
|
||||
res.redirect('/tech/login');
|
||||
});
|
||||
@@ -176,7 +201,7 @@ router.get('/', requireTechSession, (req, res) => {
|
||||
|
||||
// --- OPEN TICKETS (Pool) ---
|
||||
router.get('/pool', requireTechSession, (req, res) => {
|
||||
const openTickets = techSvc.getOpenTickets();
|
||||
const openTickets = techSvc.getOpenTickets(req.session.techId);
|
||||
res.render('tech/pool', {
|
||||
title: 'Tiket Baru',
|
||||
company: company(),
|
||||
@@ -201,8 +226,15 @@ router.get('/history', requireTechSession, (req, res) => {
|
||||
|
||||
// --- NETWORK MAP ---
|
||||
router.get('/map', requireTechSession, (req, res) => {
|
||||
const customers = customerSvc.getAllCustomers();
|
||||
const odps = odpSvc.getAllOdps();
|
||||
const tech = techSvc.getTechById(req.session.techId);
|
||||
let customers = customerSvc.getAllCustomers();
|
||||
let odps = odpSvc.getAllOdps();
|
||||
|
||||
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||
if (filter && filter.isRestricted) {
|
||||
customers = customers.filter(c => filter.areas.includes(String(c.area || '').trim().toLowerCase()));
|
||||
odps = odps.filter(o => !o.area || filter.areas.includes(String(o.area || '').trim().toLowerCase()));
|
||||
}
|
||||
|
||||
res.render('tech/map', {
|
||||
title: 'Peta Jaringan',
|
||||
@@ -346,7 +378,12 @@ router.get('/monitoring', requireTechSession, async (req, res) => {
|
||||
// --- CREATE CUSTOMER (Technician) ---
|
||||
router.get('/customers/new', requireTechSession, (req, res) => {
|
||||
const packages = customerSvc.getAllPackages();
|
||||
const odps = odpSvc.getAllOdps();
|
||||
let odps = odpSvc.getAllOdps();
|
||||
const tech = techSvc.getTechById(req.session.techId);
|
||||
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||
if (filter && filter.isRestricted) {
|
||||
odps = odps.filter(o => !o.area || filter.areas.includes(String(o.area || '').trim().toLowerCase()));
|
||||
}
|
||||
const routers = mikrotikService.getAllRouters();
|
||||
const olts = oltSvc.getAllOlts();
|
||||
res.render('tech/create_customer', {
|
||||
@@ -366,8 +403,20 @@ router.post('/customers', requireTechSession, express.urlencoded({ extended: tru
|
||||
const name = String(req.body.name || '').trim();
|
||||
if (!name) throw new Error('Nama pelanggan wajib diisi');
|
||||
|
||||
const tech = techSvc.getTechById(req.session.techId);
|
||||
const areaFilter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||
let areaInput = String(req.body.area || '').trim();
|
||||
if (areaFilter && areaFilter.isRestricted) {
|
||||
if (!areaInput) {
|
||||
areaInput = areaFilter.areas[0];
|
||||
} else if (!areaFilter.areas.includes(areaInput.toLowerCase())) {
|
||||
throw new Error(`Anda hanya dapat mendaftarkan pelanggan di wilayah tugas Anda (${tech.area})`);
|
||||
}
|
||||
}
|
||||
|
||||
const customerData = {
|
||||
name,
|
||||
area: areaInput,
|
||||
phone: String(req.body.phone || '').trim(),
|
||||
email: String(req.body.email || '').trim(),
|
||||
nik: String(req.body.nik || '').trim(),
|
||||
@@ -461,6 +510,16 @@ router.post('/customers', requireTechSession, express.urlencoded({ extended: tru
|
||||
// API Endpoints for Technician
|
||||
const customerDevice = require('../services/customerDeviceService');
|
||||
|
||||
// Teknisi hanya boleh menyentuh perangkat pelanggan di wilayahnya.
|
||||
function checkTechTagAccess(req, tag) {
|
||||
const tech = req.session?.techId ? techSvc.getTechById(req.session.techId) : null;
|
||||
const filter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||
if (!filter || !filter.isRestricted) return true;
|
||||
const cust = customerSvc.findCustomerByAny(tag);
|
||||
if (!cust) return true;
|
||||
return filter.areas.includes(String(cust.area || '').trim().toLowerCase());
|
||||
}
|
||||
|
||||
router.get('/api/mikrotik/pppoe-users', requireTechSession, async (req, res) => {
|
||||
try {
|
||||
const routerId = req.query.routerId ? Number(req.query.routerId) : null;
|
||||
@@ -499,10 +558,15 @@ router.get('/api/odps/:id/ports', requireTechSession, (req, res) => {
|
||||
router.get('/api/devices', requireTechSession, async (req, res) => {
|
||||
try {
|
||||
const { search, status, acs, limit = 999999, offset = 0 } = req.query;
|
||||
const customers = db.prepare('SELECT id, name, phone, pppoe_username, genieacs_tag FROM customers').all();
|
||||
const tech = techSvc.getTechById(req.session.techId);
|
||||
const areaFilter = tech ? techSvc.getTechAreaWhere(tech, 'c') : null;
|
||||
const customers = db.prepare('SELECT id, name, phone, pppoe_username, genieacs_tag, area FROM customers').all();
|
||||
const allowedCustomerIds = new Set();
|
||||
const byPppoe = new Map();
|
||||
const byTag = new Map();
|
||||
for (const c of customers) {
|
||||
if (areaFilter && areaFilter.isRestricted && !areaFilter.areas.includes(String(c.area || '').trim().toLowerCase())) continue;
|
||||
allowedCustomerIds.add(c.id);
|
||||
const pu = String(c.pppoe_username || '').trim().toLowerCase();
|
||||
const tg = String(c.genieacs_tag || '').trim();
|
||||
if (pu) byPppoe.set(pu, c);
|
||||
@@ -549,6 +613,10 @@ router.get('/api/devices', requireTechSession, async (req, res) => {
|
||||
};
|
||||
});
|
||||
|
||||
if (areaFilter && areaFilter.isRestricted) {
|
||||
devices = devices.filter(d => d.customerId && allowedCustomerIds.has(d.customerId));
|
||||
}
|
||||
|
||||
if (search) {
|
||||
const s = search.toLowerCase();
|
||||
devices = devices.filter(d =>
|
||||
@@ -575,6 +643,7 @@ router.get('/api/devices', requireTechSession, async (req, res) => {
|
||||
|
||||
router.get('/api/device/:tag', requireTechSession, async (req, res) => {
|
||||
try {
|
||||
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
|
||||
const data = await customerDevice.getCustomerDeviceData(req.params.tag);
|
||||
if (!data || data.status === 'Tidak ditemukan') return res.status(404).json({ error: 'Device not found' });
|
||||
res.json(data);
|
||||
@@ -584,6 +653,7 @@ router.get('/api/device/:tag', requireTechSession, async (req, res) => {
|
||||
});
|
||||
|
||||
router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (req, res) => {
|
||||
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
|
||||
const { ssid } = req.body;
|
||||
if (!ssid) return res.status(400).json({ error: 'SSID required' });
|
||||
const ok = await customerDevice.updateSSID(req.params.tag, ssid);
|
||||
@@ -616,6 +686,7 @@ router.post('/api/device/:tag/ssid', requireTechSession, express.json(), async (
|
||||
});
|
||||
|
||||
router.post('/api/device/:tag/password', requireTechSession, express.json(), async (req, res) => {
|
||||
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
|
||||
const { password } = req.body;
|
||||
if (!password || password.length < 8) return res.status(400).json({ error: 'Password minimal 8 karakter' });
|
||||
const ok = await customerDevice.updatePassword(req.params.tag, password);
|
||||
@@ -648,6 +719,7 @@ router.post('/api/device/:tag/password', requireTechSession, express.json(), asy
|
||||
});
|
||||
|
||||
router.post('/api/device/:tag/reboot', requireTechSession, async (req, res) => {
|
||||
if (!checkTechTagAccess(req, req.params.tag)) return res.status(403).json({ error: 'Perangkat di luar wilayah tugas Anda' });
|
||||
const result = await customerDevice.requestReboot(req.params.tag);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
587
services/adminAiTools.js
Normal file
@@ -0,0 +1,587 @@
|
||||
const db = require('../config/database');
|
||||
const customerSvc = require('./customerService');
|
||||
const billingSvc = require('./billingService');
|
||||
const mikrotikService = require('./mikrotikService');
|
||||
const customerDevice = require('./customerDeviceService');
|
||||
const agentSvc = require('./agentService');
|
||||
const { getSetting, getCurrentDateInTimezone } = require('../config/settingsManager');
|
||||
const { logger } = require('../config/logger');
|
||||
|
||||
/**
|
||||
* Deklarasi Tools (Function Calling) untuk Google Gemini API
|
||||
*/
|
||||
const adminToolDeclarations = [
|
||||
{
|
||||
name: 'add_customer',
|
||||
description: 'Mendaftarkan / menambah pelanggan internet baru ke database billing dan MikroTik',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
name: { type: 'STRING', description: 'Nama lengkap pelanggan' },
|
||||
phone: { type: 'STRING', description: 'Nomor WhatsApp atau telepon pelanggan' },
|
||||
packageName: { type: 'STRING', description: 'Nama paket langganan (misal: BRONZE, SILVER, GOLD, dll)' },
|
||||
area: { type: 'STRING', description: 'Nama area / dusun / lokasi pelanggan (opsional)' },
|
||||
address: { type: 'STRING', description: 'Alamat lengkap rumah pelanggan (opsional)' },
|
||||
pppoeUsername: { type: 'STRING', description: 'Username akun PPPoE untuk dial MikroTik (opsional)' },
|
||||
pppoePassword: { type: 'STRING', description: 'Password akun PPPoE (opsional)' }
|
||||
},
|
||||
required: ['name', 'phone']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'delete_customer_request',
|
||||
description: 'Meminta konfirmasi penghapusan data pelanggan secara permanen dari sistem',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, nomor WhatsApp, atau username PPPoE yang ingin dihapus' }
|
||||
},
|
||||
required: ['target']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'check_modem_status',
|
||||
description: 'Mengecek kondisi modem ONU, daya redaman optik sinyal (RX Power), status PPPoE, dan uptime pelanggan via GenieACS dan MikroTik',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target: { type: 'STRING', description: 'Nama pelanggan, nomor telepon, akun PPPoE, atau tag modem ONU yang ingin dicek' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'reboot_onu',
|
||||
description: 'Me-reboot / restart modem ONU pelanggan dari jarak jauh via GenieACS TR-069',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target: { type: 'STRING', description: 'Nama pelanggan, ID, no HP, akun PPPoE, atau tag ONU' }
|
||||
},
|
||||
required: ['target']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'update_wifi_password',
|
||||
description: 'Mengubah kata sandi WiFi modem ONU pelanggan via GenieACS TR-069',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target: { type: 'STRING', description: 'Nama pelanggan, no HP, akun PPPoE, atau tag ONU' },
|
||||
newPassword: { type: 'STRING', description: 'Kata sandi WiFi baru (minimal 8 karakter)' }
|
||||
},
|
||||
required: ['target', 'newPassword']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'update_wifi_ssid',
|
||||
description: 'Mengubah nama SSID WiFi modem ONU pelanggan via GenieACS TR-069',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target: { type: 'STRING', description: 'Nama pelanggan, no HP, akun PPPoE, atau tag ONU' },
|
||||
newSsid: { type: 'STRING', description: 'Nama SSID WiFi baru' }
|
||||
},
|
||||
required: ['target', 'newSsid']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'mark_invoice_paid',
|
||||
description: 'Menandai lunas / melunasi tagihan invoice pelanggan dan cabut isolir jika ada',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target: { type: 'STRING', description: 'ID invoice (angka), nama pelanggan, no HP, atau username PPPoE' }
|
||||
},
|
||||
required: ['target']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'isolate_customer',
|
||||
description: 'Mengisolir / suspend / memutus sementara layanan internet pelanggan di billing dan MikroTik',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, no HP, atau username PPPoE' }
|
||||
},
|
||||
required: ['target']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'unisolate_customer',
|
||||
description: 'Membuka isolir / mengaktifkan kembali layanan internet pelanggan di billing dan MikroTik',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
target: { type: 'STRING', description: 'Nama pelanggan, ID pelanggan, no HP, atau username PPPoE' }
|
||||
},
|
||||
required: ['target']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'kick_pppoe_user',
|
||||
description: 'Memutus paksa / kick sesi active PPPoE atau Hotspot pelanggan di router MikroTik',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
username: { type: 'STRING', description: 'Username PPPoE atau nama pelanggan' }
|
||||
},
|
||||
required: ['username']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'create_hotspot_voucher',
|
||||
description: 'Membuat / generate voucher hotspot baru di router MikroTik (Mikhmon profile)',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
profile: { type: 'STRING', description: 'Nama profil paket voucher (misal: 1jam, 5jam, 24jam, dll)' },
|
||||
count: { type: 'INTEGER', description: 'Jumlah voucher yang ingin dibuat (opsional, default 1)' }
|
||||
},
|
||||
required: ['profile']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'generate_invoices',
|
||||
description: 'Menjalankan generate invoice tagihan bulanan seluruh pelanggan',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
month: { type: 'INTEGER', description: 'Bulan (1-12)' },
|
||||
year: { type: 'INTEGER', description: 'Tahun (misal: 2026)' }
|
||||
},
|
||||
required: ['month', 'year']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'check_digiflazz_balance',
|
||||
description: 'Mengecek sisa saldo deposit Digiflazz (PPOB / Pulsa) Admin',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'topup_agent',
|
||||
description: 'Mentransfer / top up saldo agen reseller voucher atau pembayaran',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
agentKey: { type: 'STRING', description: 'Username agent, nama agent, no HP, atau ID agent' },
|
||||
amount: { type: 'INTEGER', description: 'Nominal saldo yang ditransfer (angka dalam Rupiah)' },
|
||||
note: { type: 'STRING', description: 'Catatan transfer (opsional)' }
|
||||
},
|
||||
required: ['agentKey', 'amount']
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'auto_heal_network',
|
||||
description: 'Menjalankan diagnosa dan perbaikan mandiri otomatis (Self-Healing) pada seluruh jaringan, router MikroTik (sinkronisasi secret PPPoE & isolir), status pelanggan billing, dan sesi yang macet',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {
|
||||
mode: { type: 'STRING', description: 'Mode perbaikan: "repair" untuk langsung memperbaiki otomatis, atau "scan" untuk diagnosa saja (opsional, default: "repair")' }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'check_security_status',
|
||||
description: 'Mengecek status keamanan sistem, apakah ada anomali serangan brute force pada portal login aplikasi maupun router MikroTik, serta IP penyerang yang diblokir',
|
||||
parameters: {
|
||||
type: 'OBJECT',
|
||||
properties: {}
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper untuk mencari pelanggan secara fleksibel (Nama, HP, PPPoE, ID, Tag)
|
||||
*/
|
||||
function findCustomerHelper(query) {
|
||||
if (!query) return null;
|
||||
const q = String(query).trim();
|
||||
let cust = customerSvc.findCustomerByAny(q);
|
||||
if (cust) return cust;
|
||||
|
||||
const byExactName = db.prepare('SELECT id FROM customers WHERE LOWER(name) = LOWER(?) LIMIT 1').get(q);
|
||||
if (byExactName) return customerSvc.getCustomerById(byExactName.id);
|
||||
|
||||
const candidates = customerSvc.getAllCustomers(q) || [];
|
||||
if (candidates.length === 1) return customerSvc.getCustomerById(candidates[0].id);
|
||||
if (candidates.length > 1) {
|
||||
const exact = candidates.find(c => c.name.toLowerCase() === q.toLowerCase());
|
||||
if (exact) return customerSvc.getCustomerById(exact.id);
|
||||
return customerSvc.getCustomerById(candidates[0].id);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eksekutor Tool / Fungsi Perintah WhatsApp Admin
|
||||
*/
|
||||
async function executeAdminTool(toolName, args, extraContext = {}) {
|
||||
const { reply, sock, lidStore } = extraContext;
|
||||
logger.info(`[Admin AI Tool] Memulai eksekusi tool "${toolName}" dengan args: ${JSON.stringify(args)}`);
|
||||
|
||||
try {
|
||||
switch (toolName) {
|
||||
// 1. Tambah Pelanggan
|
||||
case 'add_customer': {
|
||||
const { name, phone, packageName, area, address, pppoeUsername, pppoePassword } = args;
|
||||
if (!name || !phone) {
|
||||
return 'Mohon sebutkan minimal nama dan nomor WhatsApp pelanggan yang ingin didaftarkan.';
|
||||
}
|
||||
|
||||
const allPkgs = customerSvc.getAllPackages();
|
||||
let pkg = null;
|
||||
if (packageName) {
|
||||
pkg = allPkgs.find(p => p.name.toLowerCase() === packageName.toLowerCase().trim()) ||
|
||||
allPkgs.find(p => p.name.toLowerCase().includes(packageName.toLowerCase().trim()));
|
||||
}
|
||||
if (!pkg && allPkgs.length > 0) {
|
||||
pkg = allPkgs[0];
|
||||
}
|
||||
if (!pkg) {
|
||||
return 'Mohon maaf, saat ini belum ada paket layanan internet yang aktif di database.';
|
||||
}
|
||||
|
||||
let cleanPppoe = (pppoeUsername || name.toLowerCase().replace(/[^a-z0-9]/g, '')).trim();
|
||||
if (!cleanPppoe) cleanPppoe = 'user' + Math.floor(1000 + Math.random() * 9000);
|
||||
|
||||
const existing = db.prepare('SELECT id FROM customers WHERE pppoe_username = ?').get(cleanPppoe);
|
||||
if (existing) {
|
||||
cleanPppoe += Math.floor(10 + Math.random() * 89);
|
||||
}
|
||||
const cleanPass = (pppoePassword || cleanPppoe).trim();
|
||||
|
||||
const routerRow = db.prepare('SELECT id FROM routers WHERE is_active = 1 LIMIT 1').get();
|
||||
const routerId = routerRow ? routerRow.id : null;
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const newCustData = {
|
||||
name: name.trim(),
|
||||
phone: String(phone).replace(/\D/g, ''),
|
||||
email: '',
|
||||
address: address ? String(address).trim() : '',
|
||||
area: area ? String(area).trim() : '',
|
||||
package_id: pkg.id,
|
||||
router_id: routerId,
|
||||
connection_type: 'pppoe',
|
||||
pppoe_username: cleanPppoe,
|
||||
pppoe_password: cleanPass,
|
||||
status: 'active',
|
||||
install_date: today,
|
||||
auto_isolate: 1,
|
||||
isolate_day: 10
|
||||
};
|
||||
|
||||
const res = customerSvc.createCustomer(newCustData);
|
||||
const newId = res.lastInsertRowid;
|
||||
|
||||
if (routerId) {
|
||||
try {
|
||||
await mikrotikService.createPppoeSecret({
|
||||
username: cleanPppoe,
|
||||
password: cleanPass,
|
||||
profile: pkg.name,
|
||||
routerId: routerId
|
||||
});
|
||||
} catch (err) {
|
||||
logger.warn(`[Admin AI Tool] Buat secret mikrotik: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
billingSvc.ensureCurrentInvoice(newId);
|
||||
} catch (_) {}
|
||||
|
||||
return `Pelanggan baru atas nama ${name} dengan nomor WhatsApp ${newCustData.phone} berhasil didaftarkan dengan paket ${pkg.name}. Akun internet PPPoE dan tagihan periode berjalan sudah dibuatkan secara otomatis.`;
|
||||
}
|
||||
|
||||
// 2. Hapus Pelanggan (Permintaan Konfirmasi)
|
||||
case 'delete_customer_request': {
|
||||
const { target } = args;
|
||||
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin dihapus.';
|
||||
const cust = findCustomerHelper(target);
|
||||
if (!cust) {
|
||||
return `Pelanggan atas nama ${target} tidak ditemukan di sistem. Silakan pastikan nama atau nomornya sudah sesuai.`;
|
||||
}
|
||||
|
||||
return `Apakah Anda yakin ingin menghapus data pelanggan ${cust.name}? Tindakan ini bersifat permanen. Jika setuju, silakan konfirmasi dengan ucapan: ya hapus ${cust.id}.`;
|
||||
}
|
||||
|
||||
// 3. Cek Status Modem & Redaman
|
||||
case 'check_modem_status': {
|
||||
const { target } = args || {};
|
||||
if (!target || target.toLowerCase() === 'semua' || target.toLowerCase() === 'general') {
|
||||
return 'Secara umum kondisi jaringan dan modem pelanggan yang aktif terpantau normal dan lancar. Jika Anda ingin mengecek kondisi modem pelanggan tertentu, silakan sebutkan nama pelanggannya.';
|
||||
}
|
||||
|
||||
const cust = findCustomerHelper(target);
|
||||
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
|
||||
let onuData = null;
|
||||
try {
|
||||
onuData = await Promise.race([
|
||||
customerDevice.getCustomerDeviceData(tagToken),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 4000))
|
||||
]).catch(() => null);
|
||||
} catch (_) {}
|
||||
|
||||
const pppoeUser = cust?.pppoe_username ? cust.pppoe_username.toLowerCase() : '';
|
||||
let pppoeSession = null;
|
||||
if (pppoeUser) {
|
||||
try {
|
||||
const activeSessions = await mikrotikService.getActivePppoeUsers();
|
||||
pppoeSession = (activeSessions || []).find(s => (s.user || '').toLowerCase() === pppoeUser);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const name = cust ? cust.name : target;
|
||||
if (!onuData && !pppoeSession) {
|
||||
return `Perangkat modem untuk pelanggan ${name} saat ini belum terdeteksi aktif di sistem GenieACS maupun router MikroTik. Pastikan modem telah menyala dan kabel fiber optik terhubung dengan baik.`;
|
||||
}
|
||||
|
||||
let redamanDesc = '';
|
||||
if (onuData?.rxPower) {
|
||||
const rx = parseFloat(onuData.rxPower);
|
||||
if (!isNaN(rx)) {
|
||||
if (rx > -23) {
|
||||
redamanDesc = `Kualitas sinyal redaman optiknya sangat bagus di minus ${Math.abs(rx)} desibel.`;
|
||||
} else if (rx >= -27) {
|
||||
redamanDesc = `Kualitas sinyal redaman optiknya cukup baik di minus ${Math.abs(rx)} desibel.`;
|
||||
} else {
|
||||
redamanDesc = `Kualitas sinyal redaman optiknya agak lemah di minus ${Math.abs(rx)} desibel, perlu diperhatikan.`;
|
||||
}
|
||||
} else {
|
||||
redamanDesc = `Kualitas daya sinyal tercatat ${onuData.rxPower}.`;
|
||||
}
|
||||
}
|
||||
|
||||
let pppoeDesc = pppoeSession ? 'Koneksi internet terpantau online lancar.' : 'Akun internet terpantau belum dial up ke router.';
|
||||
return `Kondisi modem milik ${name} saat ini terpantau normal dan aktif. ${redamanDesc} ${pppoeDesc}`.trim();
|
||||
}
|
||||
|
||||
// 4. Reboot Modem ONU
|
||||
case 'reboot_onu': {
|
||||
const { target } = args;
|
||||
if (!target) return 'Mohon sebutkan nama pelanggan atau tag modem yang ingin dinyalakan ulang.';
|
||||
|
||||
const cust = findCustomerHelper(target);
|
||||
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
|
||||
|
||||
const targetDevice = await customerDevice.resolveDeviceToken(tagToken);
|
||||
if (!targetDevice) {
|
||||
return `Perangkat modem untuk ${target} tidak ditemukan di GenieACS.`;
|
||||
}
|
||||
|
||||
await customerDevice.requestReboot(tagToken);
|
||||
return `Perintah restart untuk modem milik ${cust ? cust.name : target} berhasil dikirim. Modem akan mulai menyala ulang dalam beberapa saat.`;
|
||||
}
|
||||
|
||||
// 5. Update Password WiFi
|
||||
case 'update_wifi_password': {
|
||||
const { target, newPassword } = args;
|
||||
if (!target || !newPassword) return 'Mohon sebutkan nama pelanggan dan kata sandi wifi baru.';
|
||||
if (String(newPassword).length < 8) return 'Kata sandi wifi baru minimal harus delapan karakter.';
|
||||
|
||||
const cust = findCustomerHelper(target);
|
||||
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
|
||||
|
||||
const ok = await customerDevice.updatePassword(tagToken, String(newPassword).trim());
|
||||
if (ok) {
|
||||
return `Kata sandi wifi untuk pelanggan ${cust ? cust.name : target} berhasil diperbarui menjadi ${newPassword}. Pengaturan sudah diterapkan ke modem.`;
|
||||
} else {
|
||||
return `Gagal mengubah kata sandi wifi untuk ${target}. Pastikan modem pelanggan sedang dalam kondisi online.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Update SSID WiFi
|
||||
case 'update_wifi_ssid': {
|
||||
const { target, newSsid } = args;
|
||||
if (!target || !newSsid) return 'Mohon sebutkan nama pelanggan dan nama wifi baru.';
|
||||
|
||||
const cust = findCustomerHelper(target);
|
||||
const tagToken = cust ? (cust.genieacs_tag || cust.pppoe_username || cust.phone) : target;
|
||||
|
||||
const ok = await customerDevice.updateSSID(tagToken, String(newSsid).trim());
|
||||
if (ok) {
|
||||
return `Nama wifi untuk pelanggan ${cust ? cust.name : target} berhasil diganti menjadi ${newSsid}. Perangkat modem sudah diperbarui.`;
|
||||
} else {
|
||||
return `Gagal mengubah nama wifi untuk ${target}. Pastikan modem pelanggan sedang aktif terhubung.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 7. Melunasi Tagihan
|
||||
case 'mark_invoice_paid': {
|
||||
const { target } = args;
|
||||
if (!target) return 'Mohon sebutkan ID invoice atau nama pelanggan yang tagihannya ingin dilunasi.';
|
||||
const { executeAdminLunas } = await import('./whatsappBot.mjs');
|
||||
let capturedReply = '';
|
||||
const localReply = async (msg) => { capturedReply = msg; };
|
||||
await executeAdminLunas(target, localReply, sock, lidStore);
|
||||
return `Tagihan untuk ${target} telah berhasil dilunasi di sistem dan layanan sudah aktif normal.`;
|
||||
}
|
||||
|
||||
// 8. Isolir Pelanggan
|
||||
case 'isolate_customer': {
|
||||
const { target } = args;
|
||||
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin diisolir.';
|
||||
const { executeAdminIsolir } = await import('./whatsappBot.mjs');
|
||||
let capturedReply = '';
|
||||
const localReply = async (msg) => { capturedReply = msg; };
|
||||
await executeAdminIsolir(target, localReply);
|
||||
return `Layanan internet untuk pelanggan ${target} telah berhasil diisolir sementara.`;
|
||||
}
|
||||
|
||||
// 9. Buka Isolir Pelanggan
|
||||
case 'unisolate_customer': {
|
||||
const { target } = args;
|
||||
if (!target) return 'Mohon sebutkan nama pelanggan atau ID yang ingin dibuka isolirnya.';
|
||||
const { executeAdminBuka } = await import('./whatsappBot.mjs');
|
||||
let capturedReply = '';
|
||||
const localReply = async (msg) => { capturedReply = msg; };
|
||||
await executeAdminBuka(target, localReply);
|
||||
return `Isolir untuk pelanggan ${target} telah berhasil dibuka. Koneksi internet kini sudah normal kembali.`;
|
||||
}
|
||||
|
||||
// 10. Kick PPPoE / Hotspot User
|
||||
case 'kick_pppoe_user': {
|
||||
const { username } = args;
|
||||
if (!username) return 'Mohon sebutkan nama pengguna yang ingin diputus sesinya.';
|
||||
|
||||
let targetUser = username.trim();
|
||||
const cust = findCustomerHelper(username);
|
||||
if (cust && cust.pppoe_username) {
|
||||
targetUser = cust.pppoe_username;
|
||||
}
|
||||
|
||||
const pk = await mikrotikService.kickPppoeUser(targetUser);
|
||||
const hk = await mikrotikService.kickHotspotUser(targetUser);
|
||||
if (pk || hk) {
|
||||
return `Sesi aktif untuk ${targetUser} telah berhasil diputus dari router. Perangkat akan terhubung kembali secara otomatis.`;
|
||||
} else {
|
||||
return `Sesi aktif untuk ${targetUser} tidak ditemukan di router.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 11. Buat Voucher Hotspot
|
||||
case 'create_hotspot_voucher': {
|
||||
const { profile, count } = args;
|
||||
if (!profile) return 'Mohon sebutkan nama profil paket voucher hotspot yang ingin dibuat.';
|
||||
|
||||
const numVouchers = Math.min(Math.max(parseInt(count, 10) || 1, 1), 10);
|
||||
const created = [];
|
||||
const now = getCurrentDateInTimezone();
|
||||
const dateStr = `${String(now.getDate()).padStart(2, '0')}/${String(now.getMonth() + 1).padStart(2, '0')}/${now.getFullYear()}`;
|
||||
|
||||
for (let i = 0; i < numVouchers; i++) {
|
||||
const code = Math.floor(1000 + Math.random() * 9000).toString();
|
||||
const comment = `vc ${code} ${dateStr} AI`;
|
||||
await mikrotikService.addHotspotUser({
|
||||
name: code,
|
||||
password: code,
|
||||
profile: profile,
|
||||
comment: comment
|
||||
});
|
||||
created.push(code);
|
||||
}
|
||||
|
||||
return `Sebanyak ${created.length} voucher hotspot dengan paket ${profile} telah berhasil dibuat. Kode voucher pertama adalah ${created[0]}.`;
|
||||
}
|
||||
|
||||
// 12. Generate Tagihan Bulanan
|
||||
case 'generate_invoices': {
|
||||
const { month, year } = args;
|
||||
const m = parseInt(month, 10) || (new Date().getMonth() + 1);
|
||||
const y = parseInt(year, 10) || new Date().getFullYear();
|
||||
|
||||
const r = await billingSvc.generateMonthlyInvoices(m, y);
|
||||
return `Pembuatan tagihan bulanan untuk periode bulan ${m} tahun ${y} telah selesai. Sebanyak ${r?.created || 0} tagihan baru berhasil diproses.`;
|
||||
}
|
||||
|
||||
// 13. Cek Saldo Digiflazz
|
||||
case 'check_digiflazz_balance': {
|
||||
const r = await agentSvc.digiflazzCheckBalance();
|
||||
const dep = Number(r?.deposit || 0).toLocaleString('id-ID');
|
||||
return `Sisa saldo deposit Digiflazz Anda saat ini tercatat sebesar ${dep} rupiah dan berstatus aktif.`;
|
||||
}
|
||||
|
||||
// 14. Top Up Saldo Agent
|
||||
case 'topup_agent': {
|
||||
const { agentKey, amount, note } = args;
|
||||
const nom = Number(String(amount || '').replace(/[^\d]/g, ''));
|
||||
if (!nom || nom <= 0) return 'Nominal top up harus berupa angka lebih dari nol.';
|
||||
|
||||
const rawKey = String(agentKey || '').trim();
|
||||
let agent = agentSvc.getAgentById(parseInt(rawKey, 10)) ||
|
||||
agentSvc.getAgentByUsername(rawKey) ||
|
||||
agentSvc.getAgentByPhone(rawKey);
|
||||
|
||||
if (!agent) {
|
||||
const all = agentSvc.getAllAgents();
|
||||
agent = all.find(a => String(a.name || '').toLowerCase().includes(rawKey.toLowerCase()));
|
||||
}
|
||||
|
||||
if (!agent) return `Data agen ${agentKey} tidak ditemukan di sistem.`;
|
||||
|
||||
const r = agentSvc.topupAgent(agent.id, nom, note || 'Topup via Voice AI', 'Admin Voice AI');
|
||||
return `Top up saldo untuk agen ${agent.name} sebesar ${nom.toLocaleString('id-ID')} rupiah telah berhasil diproses. Saldo saat ini menjadi ${Number(r.after || 0).toLocaleString('id-ID')} rupiah.`;
|
||||
}
|
||||
|
||||
// 15. Self-Healing & Network Auto-Repair
|
||||
case 'auto_heal_network': {
|
||||
const { mode } = args || {};
|
||||
const selfHealingSvc = require('./selfHealingService');
|
||||
const isRepair = String(mode || 'repair').toLowerCase() !== 'scan';
|
||||
const res = await (isRepair ? selfHealingSvc.autoRepair('Admin AI Command') : selfHealingSvc.diagnose());
|
||||
|
||||
const found = res.stats.anomaliesFound;
|
||||
const fixed = res.stats.anomaliesRepaired;
|
||||
const routers = res.stats.routersChecked;
|
||||
|
||||
if (found === 0) {
|
||||
return `Pemeriksaan mandiri selesai. Seluruh sistem billing, status pelanggan, dan ${routers} router MikroTik terpantau normal dan tersinkronisasi sempurna tanpa kendala.`;
|
||||
}
|
||||
|
||||
if (isRepair) {
|
||||
const repairList = res.repairs.slice(0, 3).map(r => r.replace(/\[[^\]]+\]\s*/, '')).join('. ');
|
||||
return `Proses perbaikan mandiri selesai. Sistem mendeteksi ${found} anomali dan berhasil memperbaiki ${fixed} masalah secara otomatis: ${repairList}.`;
|
||||
} else {
|
||||
return `Pemeriksaan sistem mendeteksi ${found} anomali yang perlu disinkronkan. Anda dapat memerintahkan perbaikan dengan ucapan: ai perbaiki jaringan.`;
|
||||
}
|
||||
}
|
||||
|
||||
// 16. Cek Status Keamanan & Brute Force Anomaly
|
||||
case 'check_security_status': {
|
||||
const securityWatchdog = require('./securityWatchdogService');
|
||||
const overview = securityWatchdog.getSecurityOverview();
|
||||
const blockedCount = overview.activeBlockedIps ? overview.activeBlockedIps.length : 0;
|
||||
const recentAlerts = overview.recentAlerts || [];
|
||||
|
||||
if (blockedCount === 0 && recentAlerts.length === 0) {
|
||||
return 'Status keamanan sistem saat ini aman dan terkendali. Tidak terdeteksi adanya anomali serangan brute force pada portal login aplikasi maupun router MikroTik.';
|
||||
}
|
||||
|
||||
let resp = `Laporan keamanan sistem: Saat ini status sistem adalah ${overview.status === 'warning' ? 'waspada' : 'aman'}. `;
|
||||
if (blockedCount > 0) {
|
||||
const ipList = overview.activeBlockedIps.map(b => `${b.ip} (tersisa ${b.remainingSeconds} detik)`).join(', ');
|
||||
resp += `Terdapat ${blockedCount} alamat i pe yang sedang diblokir sementara karena percobaan login berulang kali: ${ipList}. `;
|
||||
}
|
||||
if (recentAlerts.length > 0) {
|
||||
resp += `Tercatat ${recentAlerts.length} riwayat insiden keamanan terbaru pada audit sistem.`;
|
||||
}
|
||||
return resp;
|
||||
}
|
||||
|
||||
default:
|
||||
return `Fungsi ${toolName} belum tersedia di sistem.`;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[Admin AI Tool] Error saat eksekusi ${toolName}: ${err.message}`);
|
||||
return `Mohon maaf, terjadi kendala saat memproses permintaan: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
adminToolDeclarations,
|
||||
executeAdminTool
|
||||
};
|
||||
@@ -397,6 +397,19 @@ function markAsPaid(invoiceId, paidByName, notes, actor = null) {
|
||||
UPDATE invoices SET status='paid', paid_at=NOW_LOCAL(), paid_by_name=?, notes=? WHERE id=? AND status != 'paid'
|
||||
`).run(paidByName || 'Admin', finalNotes, invId);
|
||||
|
||||
if (result.changes > 0) {
|
||||
try {
|
||||
const soundboxSvc = require('./soundboxService');
|
||||
const cust = db.prepare('SELECT name FROM customers WHERE id=?').get(existing.customer_id);
|
||||
soundboxSvc.queueSoundboxEvent({
|
||||
type: 'invoice',
|
||||
title: 'Tagihan #' + invId,
|
||||
amount: existing.amount,
|
||||
customerName: cust?.name || 'Pelanggan'
|
||||
});
|
||||
} catch (e) { /* soundbox opsional */ }
|
||||
}
|
||||
|
||||
if (result.changes > 0 && existing.customer_id) {
|
||||
renewCustomerPrepaidValidity(existing.customer_id, 1);
|
||||
try { db.prepare("UPDATE customers SET unpaid_streak = 0, deactivate_cause = '', deactivated_at = NULL WHERE id = ?").run(existing.customer_id); } catch (e) {}
|
||||
|
||||
@@ -808,6 +808,33 @@ function startCronJobs() {
|
||||
} catch (e) { logger.error('[CRON] Retensi notif webhook error: ' + e.message); }
|
||||
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
|
||||
|
||||
// AI Self-Healing & Network Auto-Repair (tiap 30 menit, bisa dimatikan dari Pengaturan)
|
||||
cron.schedule('*/30 * * * *', async () => {
|
||||
const enabled = getSetting('ai_self_healing_enabled', false);
|
||||
if (enabled !== true && enabled !== 'true') return;
|
||||
try {
|
||||
const selfHealingSvc = require('./selfHealingService');
|
||||
const res = await selfHealingSvc.autoRepair('AI Autonomous Watchdog');
|
||||
if (res && res.stats && res.stats.anomaliesRepaired > 0) {
|
||||
logger.info('[CRON-AI-HEAL] Memperbaiki ' + res.stats.anomaliesRepaired + ' anomali: ' + (res.repairs || []).join('; '));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[CRON-AI-HEAL] Error: ' + e.message);
|
||||
}
|
||||
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
|
||||
|
||||
// Security Watchdog - scan login gagal MikroTik (tiap 2 menit)
|
||||
cron.schedule('*/2 * * * *', async () => {
|
||||
try {
|
||||
const securityWatchdog = require('./securityWatchdogService');
|
||||
if (typeof securityWatchdog.scanMikrotikLoginFailures === 'function') {
|
||||
await securityWatchdog.scanMikrotikLoginFailures();
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('[CRON-SECURITY-WATCHDOG] Error: ' + e.message);
|
||||
}
|
||||
}, { timezone: getSetting('timezone', 'Asia/Jakarta') });
|
||||
|
||||
logger.info('[CRON] Semua tugas penjadwalan telah aktif.');
|
||||
}
|
||||
|
||||
|
||||
@@ -71,6 +71,59 @@ function calculateExpiredAt(startDateStr, durationDays = 30) {
|
||||
return `${y}-${m}-${day} 23:59:59`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Batas wilayah tugas kolektor (dukung multi-area dipisah koma).
|
||||
* area kosong / "semua" / "all" = tanpa batas wilayah.
|
||||
*/
|
||||
function getCollectorCustomerWhere(collector, tableAlias = 'c') {
|
||||
let col = collector;
|
||||
if (typeof collector === 'number' || typeof collector === 'string') {
|
||||
col = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(Number(collector));
|
||||
}
|
||||
const colId = Number(col?.id || 0);
|
||||
const areas = String(col?.area || '')
|
||||
.split(',')
|
||||
.map(a => a.trim().toLowerCase())
|
||||
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||
|
||||
if (areas.length > 0) {
|
||||
const placeholders = areas.map(() => '?').join(', ');
|
||||
return {
|
||||
where: `(LOWER(TRIM(${tableAlias}.area)) IN (${placeholders}) AND (${tableAlias}.collector_id = ? OR ${tableAlias}.collector_id IS NULL OR ${tableAlias}.collector_id = 0))`,
|
||||
params: [...areas, colId],
|
||||
isRestricted: true,
|
||||
areas,
|
||||
};
|
||||
}
|
||||
return {
|
||||
where: `(${tableAlias}.collector_id = ? OR ${tableAlias}.collector_id IS NULL OR ${tableAlias}.collector_id = 0)`,
|
||||
params: [colId],
|
||||
isRestricted: false,
|
||||
areas: [],
|
||||
};
|
||||
}
|
||||
|
||||
function canCollectorAccessCustomer(collector, customer) {
|
||||
if (!collector || !customer) return false;
|
||||
let col = collector;
|
||||
if (typeof collector === 'number' || typeof collector === 'string') {
|
||||
col = db.prepare('SELECT id, area FROM collectors WHERE id = ?').get(Number(collector));
|
||||
}
|
||||
const colId = Number(col?.id || 0);
|
||||
const custColId = Number(customer?.collector_id || 0);
|
||||
if (custColId > 0 && custColId !== colId) return false;
|
||||
|
||||
const areas = String(col?.area || '')
|
||||
.split(',')
|
||||
.map(a => a.trim().toLowerCase())
|
||||
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||
if (areas.length > 0) {
|
||||
const custArea = String(customer?.area || '').trim().toLowerCase();
|
||||
return !!custArea && areas.includes(custArea);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function getAllCustomers(search = '', routerId = null, filterStatus = '', filterArea = '', opts = {}) {
|
||||
const now = getCurrentDateInTimezone();
|
||||
const month = now.getMonth() + 1;
|
||||
@@ -955,7 +1008,7 @@ Dihapus: ${removed.join(', ') || '-'}`);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
getAllCustomers, getAllCustomerAreas, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats,
|
||||
getAllCustomers, getAllCustomerAreas, getCollectorCustomerWhere, canCollectorAccessCustomer, getCustomerById, createCustomer, updateCustomer, deleteCustomer, bulkDeleteCustomers, getCustomerStats,
|
||||
getAllPackages, getPackageById, createPackage, updatePackage, deletePackage,
|
||||
suspendCustomer, activateCustomer, autoDeactivateOverdueCustomers, generatePppoeAccount, findCustomerByAny, findCustomerForLogin, updateCustomerCablePath,
|
||||
resetPromoCyclesUsed, getEffectiveRouterId
|
||||
|
||||
@@ -1,42 +1,111 @@
|
||||
/**
|
||||
* Service: Manajemen Inventaris / Gudang (Warehouse)
|
||||
* Sistem Pelacakan Stok, Pemasukan, Pengeluaran & Sisa Stok Terintegrasi
|
||||
*/
|
||||
const db = require('../config/database');
|
||||
const { logger } = require('../config/logger');
|
||||
|
||||
// ─── DEFAULT CATEGORIES SEEDER ───────────────────────────────────────────
|
||||
function seedDefaultCategories() {
|
||||
try {
|
||||
const count = db.prepare('SELECT count(*) as c FROM inventory_categories').get().c;
|
||||
if (count === 0) {
|
||||
const defaults = [
|
||||
['Router & ONT', 'Modem ONT GPON/EPON, Router WiFi, Access Point, Wireless Bridge'],
|
||||
['Kabel & Drop Core', 'Kabel Fiber Optic Drop Core 1-2 Core, Kabel LAN UTP Cat5e/Cat6'],
|
||||
['Aksesoris Fiber Optic', 'Patchcord, Pigtail, Adapter SC, ODP, OTB, Roset FO, Joint Closure, Protection Sleeve'],
|
||||
['Aksesoris LAN & Jaringan', 'Konektor RJ45, Switch Hub, Barrel LAN, Faceplate, Modular Jack'],
|
||||
['Aksesoris Tiang & Outdoor', 'Klem Buaya, Span Wartel, Hook S, Stainless Band, Bracket Tiang'],
|
||||
['Power & Adaptor', 'Adaptor 12V 1A/2A, POE Injector, UPS Mini DC, Kabel Power']
|
||||
];
|
||||
const insert = db.prepare('INSERT OR IGNORE INTO inventory_categories (name, description) VALUES (?, ?)');
|
||||
defaults.forEach(([name, desc]) => insert.run(name, desc));
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Failed to seed default inventory categories: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── CATEGORIES ───────────────────────────────────────────────────────────
|
||||
function getAllCategories() {
|
||||
return db.prepare('SELECT * FROM inventory_categories ORDER BY name ASC').all();
|
||||
seedDefaultCategories();
|
||||
return db.prepare(`
|
||||
SELECT c.*,
|
||||
(SELECT count(*) FROM inventory_items i WHERE i.category_id = c.id) as item_count
|
||||
FROM inventory_categories c
|
||||
ORDER BY c.name ASC
|
||||
`).all();
|
||||
}
|
||||
|
||||
function createCategory(data) {
|
||||
const { name, description } = data;
|
||||
return db.prepare('INSERT INTO inventory_categories (name, description) VALUES (?, ?)').run(name, description);
|
||||
if (!name || !name.trim()) throw new Error('Nama kategori wajib diisi');
|
||||
return db.prepare('INSERT INTO inventory_categories (name, description) VALUES (?, ?)').run(name.trim(), description || '');
|
||||
}
|
||||
|
||||
function updateCategory(id, data) {
|
||||
const { name, description } = data;
|
||||
return db.prepare('UPDATE inventory_categories SET name = ?, description = ? WHERE id = ?').run(name, description, id);
|
||||
if (!name || !name.trim()) throw new Error('Nama kategori wajib diisi');
|
||||
return db.prepare('UPDATE inventory_categories SET name = ?, description = ? WHERE id = ?').run(name.trim(), description || '', id);
|
||||
}
|
||||
|
||||
function deleteCategory(id) {
|
||||
const itemsCount = db.prepare('SELECT count(*) as c FROM inventory_items WHERE category_id = ?').get(id)?.c || 0;
|
||||
if (itemsCount > 0) {
|
||||
throw new Error(`Tidak dapat menghapus kategori karena masih memiliki ${itemsCount} jenis barang terkait.`);
|
||||
}
|
||||
return db.prepare('DELETE FROM inventory_categories WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ─── HELPER STOCK COUNT ───────────────────────────────────────────────────
|
||||
function getItemAvailableStock(itemId) {
|
||||
const res = db.prepare(`
|
||||
SELECT COALESCE(SUM(quantity), 0) as total
|
||||
FROM inventory_stock
|
||||
WHERE item_id = ? AND status = 'available'
|
||||
`).get(itemId);
|
||||
return Number(res?.total || 0);
|
||||
}
|
||||
|
||||
function getItemAssignedStock(itemId) {
|
||||
const res = db.prepare(`
|
||||
SELECT COALESCE(SUM(quantity), 0) as total
|
||||
FROM inventory_stock
|
||||
WHERE item_id = ? AND status = 'assigned'
|
||||
`).get(itemId);
|
||||
return Number(res?.total || 0);
|
||||
}
|
||||
|
||||
function getItemBrokenStock(itemId) {
|
||||
const res = db.prepare(`
|
||||
SELECT COALESCE(SUM(quantity), 0) as total
|
||||
FROM inventory_stock
|
||||
WHERE item_id = ? AND status = 'broken'
|
||||
`).get(itemId);
|
||||
return Number(res?.total || 0);
|
||||
}
|
||||
|
||||
// ─── ITEMS ───────────────────────────────────────────────────────────────
|
||||
function getAllItems(search = '') {
|
||||
function getAllItems(search = '', categoryId = null) {
|
||||
seedDefaultCategories();
|
||||
let query = `
|
||||
SELECT i.*, c.name as category_name,
|
||||
(SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available') as stock_available,
|
||||
(SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned') as stock_assigned
|
||||
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available'), 0) as stock_available,
|
||||
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned'), 0) as stock_assigned,
|
||||
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'broken'), 0) as stock_broken
|
||||
FROM inventory_items i
|
||||
LEFT JOIN inventory_categories c ON i.category_id = c.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
if (categoryId) {
|
||||
query += ' AND i.category_id = ?';
|
||||
params.push(categoryId);
|
||||
}
|
||||
if (search) {
|
||||
query += ' WHERE i.name LIKE ? OR i.brand LIKE ? OR i.model LIKE ?';
|
||||
query += ' AND (i.name LIKE ? OR i.brand LIKE ? OR i.model LIKE ? OR c.name LIKE ?)';
|
||||
const s = `%${search}%`;
|
||||
params.push(s, s, s);
|
||||
params.push(s, s, s, s);
|
||||
}
|
||||
query += ' ORDER BY i.name ASC';
|
||||
return db.prepare(query).all(...params);
|
||||
@@ -44,35 +113,495 @@ function getAllItems(search = '') {
|
||||
|
||||
function getItemById(id) {
|
||||
return db.prepare(`
|
||||
SELECT i.*, c.name as category_name
|
||||
SELECT i.*, c.name as category_name,
|
||||
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available'), 0) as stock_available,
|
||||
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned'), 0) as stock_assigned,
|
||||
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'broken'), 0) as stock_broken
|
||||
FROM inventory_items i
|
||||
LEFT JOIN inventory_categories c ON i.category_id = c.id
|
||||
WHERE i.id = ?
|
||||
`).get(id);
|
||||
}
|
||||
|
||||
function getItemDetail(id) {
|
||||
const item = getItemById(id);
|
||||
if (!item) return null;
|
||||
|
||||
const stockList = db.prepare(`
|
||||
SELECT s.*, cust.name as customer_name, cust.pppoe_username as pppoe_user
|
||||
FROM inventory_stock s
|
||||
LEFT JOIN customers cust ON s.assigned_to_customer_id = cust.id
|
||||
WHERE s.item_id = ?
|
||||
ORDER BY CASE WHEN s.status = 'available' THEN 1 WHEN s.status = 'assigned' THEN 2 ELSE 3 END, s.created_at DESC
|
||||
`).all(id);
|
||||
|
||||
const logs = db.prepare(`
|
||||
SELECT l.*, s.serial_number
|
||||
FROM inventory_logs l
|
||||
LEFT JOIN inventory_stock s ON l.stock_id = s.id
|
||||
WHERE l.item_id = ?
|
||||
ORDER BY l.created_at DESC, l.id DESC
|
||||
LIMIT 100
|
||||
`).all(id);
|
||||
|
||||
return { item, stockList, logs };
|
||||
}
|
||||
|
||||
function createItem(data) {
|
||||
const { category_id, name, brand, model, unit, min_stock, description } = data;
|
||||
if (!name || !name.trim()) throw new Error('Nama barang wajib diisi');
|
||||
return db.prepare(`
|
||||
INSERT INTO inventory_items (category_id, name, brand, model, unit, min_stock, description)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(category_id, name, brand, model, unit, min_stock, description);
|
||||
`).run(
|
||||
category_id ? Number(category_id) : null,
|
||||
name.trim(),
|
||||
brand ? brand.trim() : '',
|
||||
model ? model.trim() : '',
|
||||
unit ? unit.trim() : 'pcs',
|
||||
Number(min_stock) || 5,
|
||||
description ? description.trim() : ''
|
||||
);
|
||||
}
|
||||
|
||||
function updateItem(id, data) {
|
||||
const { category_id, name, brand, model, unit, min_stock, description } = data;
|
||||
if (!name || !name.trim()) throw new Error('Nama barang wajib diisi');
|
||||
return db.prepare(`
|
||||
UPDATE inventory_items
|
||||
SET category_id = ?, name = ?, brand = ?, model = ?, unit = ?, min_stock = ?, description = ?
|
||||
WHERE id = ?
|
||||
`).run(category_id, name, brand, model, unit, min_stock, description, id);
|
||||
`).run(
|
||||
category_id ? Number(category_id) : null,
|
||||
name.trim(),
|
||||
brand ? brand.trim() : '',
|
||||
model ? model.trim() : '',
|
||||
unit ? unit.trim() : 'pcs',
|
||||
Number(min_stock) || 5,
|
||||
description ? description.trim() : '',
|
||||
id
|
||||
);
|
||||
}
|
||||
|
||||
function deleteItem(id) {
|
||||
const stockCount = db.prepare('SELECT count(*) as c FROM inventory_stock WHERE item_id = ?').get(id)?.c || 0;
|
||||
if (stockCount > 0) {
|
||||
throw new Error(`Tidak dapat menghapus barang karena masih ada ${stockCount} data stok terkait. Silakan sesuaikan atau kosongkan stok terlebih dahulu.`);
|
||||
}
|
||||
return db.prepare('DELETE FROM inventory_items WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
// ─── STOCK ───────────────────────────────────────────────────────────────
|
||||
// ─── SERIAL NUMBERS ───────────────────────────────────────────────────────
|
||||
function getAvailableSerialNumbers(itemId) {
|
||||
return db.prepare(`
|
||||
SELECT id, serial_number, condition, location, note
|
||||
FROM inventory_stock
|
||||
WHERE item_id = ? AND status = 'available' AND serial_number IS NOT NULL AND TRIM(serial_number) != ''
|
||||
ORDER BY serial_number ASC
|
||||
`).all(itemId);
|
||||
}
|
||||
|
||||
// ─── STOCK TRANSACTIONS ───────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 1. INPUT STOK MASUK (IN)
|
||||
* Menambah stok barang dan mencatat saldo sisa stok akhir secara presisi
|
||||
*/
|
||||
function addStock(data, actor = 'Admin') {
|
||||
const { item_id, serial_number, quantity, condition, location, price, supplier, reference_no, note } = data;
|
||||
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
|
||||
|
||||
const item = getItemById(item_id);
|
||||
if (!item) throw new Error('Data barang tidak ditemukan');
|
||||
|
||||
const cleanSn = serial_number ? serial_number.trim() : '';
|
||||
const qty = cleanSn ? 1 : Math.max(1, parseInt(quantity, 10) || 1);
|
||||
|
||||
const run = db.transaction(() => {
|
||||
let stockId = null;
|
||||
|
||||
if (cleanSn) {
|
||||
// Cek duplikasi SN
|
||||
const existing = db.prepare('SELECT id, status FROM inventory_stock WHERE serial_number = ?').get(cleanSn);
|
||||
if (existing) {
|
||||
throw new Error(`Serial Number "${cleanSn}" sudah pernah terdaftar di sistem (Status: ${existing.status})!`);
|
||||
}
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, price, supplier, note)
|
||||
VALUES (?, ?, 1, ?, ?, 'available', ?, ?, ?)
|
||||
`).run(item_id, cleanSn, condition || 'new', location || 'Gudang Utama', Number(price) || 0, supplier || '', note || '');
|
||||
stockId = res.lastInsertRowid;
|
||||
} else {
|
||||
// Non-serial item
|
||||
const res = db.prepare(`
|
||||
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, price, supplier, note)
|
||||
VALUES (?, NULL, ?, ?, ?, 'available', ?, ?, ?)
|
||||
`).run(item_id, qty, condition || 'new', location || 'Gudang Utama', Number(price) || 0, supplier || '', note || '');
|
||||
stockId = res.lastInsertRowid;
|
||||
}
|
||||
|
||||
// Hitung sisa stok tersedia saat ini
|
||||
const remainingStock = getItemAvailableStock(item_id);
|
||||
|
||||
// Catat log stok masuk lengkap dengan sisa stok
|
||||
const logNote = note || (cleanSn ? `Stok Masuk SN: ${cleanSn}` : `Stok Masuk (+${qty} ${item.unit})`);
|
||||
db.prepare(`
|
||||
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
|
||||
VALUES (?, ?, 'in', ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
item_id,
|
||||
stockId,
|
||||
qty,
|
||||
remainingStock,
|
||||
actor || 'Admin',
|
||||
supplier || 'Gudang Utama',
|
||||
reference_no || '',
|
||||
logNote
|
||||
);
|
||||
|
||||
return { stockId, remainingStock };
|
||||
});
|
||||
|
||||
return run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. PENGELUARAN BARANG (OUT / ISSUE)
|
||||
* Mengeluarkan barang untuk instalasi baru, perbaikan, pemakaian teknisi, atau rusak
|
||||
* Mengurangi stok secara otomatis dan mencatat sisa stok akhir
|
||||
*/
|
||||
function issueStock(data, actor = 'Admin') {
|
||||
const { item_id, stock_id, serial_number, quantity, type, customer_id, recipient, reference_no, note } = data;
|
||||
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
|
||||
|
||||
const item = getItemById(item_id);
|
||||
if (!item) throw new Error('Data barang tidak ditemukan');
|
||||
|
||||
const availableStock = getItemAvailableStock(item_id);
|
||||
if (availableStock <= 0) {
|
||||
throw new Error(`Stok ${item.name} saat ini kosong (0 ${item.unit})!`);
|
||||
}
|
||||
|
||||
const issueType = type || (customer_id ? 'assigned' : 'out'); // 'out', 'assigned', 'broken', 'loss'
|
||||
|
||||
const run = db.transaction(() => {
|
||||
let stockIdTarget = stock_id ? Number(stock_id) : null;
|
||||
let qtyToDeduct = 1;
|
||||
|
||||
// A. JIKA DITARGETKAN KE SERIAL NUMBER SPESIFIK ATAU STOCK_ID SPESIFIK
|
||||
if (serial_number && !stockIdTarget) {
|
||||
const row = db.prepare('SELECT id, status FROM inventory_stock WHERE item_id = ? AND serial_number = ?').get(item_id, serial_number.trim());
|
||||
if (!row) throw new Error(`Serial Number "${serial_number}" tidak ditemukan untuk barang ini`);
|
||||
if (row.status !== 'available') throw new Error(`Barang SN "${serial_number}" statusnya "${row.status}", tidak dapat dikeluarkan!`);
|
||||
stockIdTarget = row.id;
|
||||
}
|
||||
|
||||
if (stockIdTarget) {
|
||||
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockIdTarget);
|
||||
if (!stock) throw new Error('Data unit stok tidak ditemukan');
|
||||
if (stock.status !== 'available') throw new Error(`Unit stok ini statusnya "${stock.status}", tidak tersedia`);
|
||||
|
||||
const targetStatus = (customer_id || issueType === 'assigned') ? 'assigned' : (issueType === 'broken' ? 'broken' : 'out');
|
||||
const targetCustomer = customer_id ? Number(customer_id) : null;
|
||||
|
||||
db.prepare(`
|
||||
UPDATE inventory_stock
|
||||
SET status = ?, assigned_to_customer_id = ?, updated_at = (NOW_LOCAL())
|
||||
WHERE id = ?
|
||||
`).run(targetStatus, targetCustomer, stockIdTarget);
|
||||
|
||||
qtyToDeduct = stock.quantity || 1;
|
||||
} else {
|
||||
// B. BARANG NON-SERIAL / PENGURANGAN KUANTITAS (FIFO)
|
||||
qtyToDeduct = Math.max(1, parseInt(quantity, 10) || 1);
|
||||
if (qtyToDeduct > availableStock) {
|
||||
throw new Error(`Stok tidak mencukupi! Tersedia di gudang: ${availableStock} ${item.unit}, diminta keluar: ${qtyToDeduct} ${item.unit}`);
|
||||
}
|
||||
|
||||
// Ambil batch stok yang available terurut dari yang paling lama masuk (FIFO)
|
||||
const batches = db.prepare(`
|
||||
SELECT id, quantity
|
||||
FROM inventory_stock
|
||||
WHERE item_id = ? AND status = 'available'
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`).all(item_id);
|
||||
|
||||
let needed = qtyToDeduct;
|
||||
for (const b of batches) {
|
||||
if (needed <= 0) break;
|
||||
if (b.quantity <= needed) {
|
||||
db.prepare("UPDATE inventory_stock SET quantity = 0, status = 'out', updated_at = (NOW_LOCAL()) WHERE id = ?").run(b.id);
|
||||
needed -= b.quantity;
|
||||
} else {
|
||||
db.prepare("UPDATE inventory_stock SET quantity = quantity - ?, updated_at = (NOW_LOCAL()) WHERE id = ?").run(needed, b.id);
|
||||
needed = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hitung sisa stok tersedia setelah pengeluaran
|
||||
const remainingStock = getItemAvailableStock(item_id);
|
||||
|
||||
// Keterangan penerima (teknisi / pelanggan)
|
||||
let finalRecipient = recipient ? recipient.trim() : '';
|
||||
if (customer_id && !finalRecipient) {
|
||||
const cust = db.prepare('SELECT name, pppoe_username as pppoe_user FROM customers WHERE id = ?').get(customer_id);
|
||||
if (cust) finalRecipient = `Pelanggan: ${cust.name} (${cust.pppoe_user || '-'})`;
|
||||
}
|
||||
if (!finalRecipient) finalRecipient = 'Teknisi / Operasional';
|
||||
|
||||
const logNote = note || `Pengeluaran Barang (-${qtyToDeduct} ${item.unit})`;
|
||||
|
||||
// Catat log pengeluaran dengan sisa stok
|
||||
db.prepare(`
|
||||
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
item_id,
|
||||
stockIdTarget,
|
||||
issueType,
|
||||
-qtyToDeduct,
|
||||
remainingStock,
|
||||
actor || 'Admin',
|
||||
finalRecipient,
|
||||
reference_no || '',
|
||||
logNote
|
||||
);
|
||||
|
||||
return { qtyDeducted: qtyToDeduct, remainingStock };
|
||||
});
|
||||
|
||||
return run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. PENYESUAIAN STOK (STOCK OPNAME / ADJUSTMENT)
|
||||
* Menyesuaikan data sistem dengan fisik gudang dan mencatat selisih & saldo akhir
|
||||
*/
|
||||
function adjustStock(data, actor = 'Admin') {
|
||||
const { item_id, actual_quantity, note } = data;
|
||||
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
|
||||
|
||||
const item = getItemById(item_id);
|
||||
if (!item) throw new Error('Data barang tidak ditemukan');
|
||||
|
||||
const actualQty = Math.max(0, parseInt(actual_quantity, 10));
|
||||
if (isNaN(actualQty)) throw new Error('Jumlah stok fisik harus berupa angka');
|
||||
|
||||
const currentAvailable = getItemAvailableStock(item_id);
|
||||
const diff = actualQty - currentAvailable;
|
||||
|
||||
if (diff === 0) {
|
||||
return { diff: 0, remainingStock: actualQty, message: 'Jumlah stok sudah sesuai, tidak ada perubahan.' };
|
||||
}
|
||||
|
||||
const run = db.transaction(() => {
|
||||
if (diff > 0) {
|
||||
// Stok fisik lebih banyak dari sistem -> Tambah selisih ke stok
|
||||
db.prepare(`
|
||||
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
|
||||
VALUES (?, NULL, ?, 'new', 'Gudang Utama', 'available', ?)
|
||||
`).run(item_id, diff, note || 'Penyesuaian Fisik (Opname)');
|
||||
} else {
|
||||
// Stok fisik lebih sedikit dari sistem -> Kurangi selisih dari stok yang ada
|
||||
let neededToReduce = Math.abs(diff);
|
||||
const batches = db.prepare(`
|
||||
SELECT id, quantity FROM inventory_stock
|
||||
WHERE item_id = ? AND status = 'available'
|
||||
ORDER BY created_at ASC, id ASC
|
||||
`).all(item_id);
|
||||
|
||||
for (const b of batches) {
|
||||
if (neededToReduce <= 0) break;
|
||||
if (b.quantity <= neededToReduce) {
|
||||
db.prepare("UPDATE inventory_stock SET quantity = 0, status = 'out', updated_at = (NOW_LOCAL()) WHERE id = ?").run(b.id);
|
||||
neededToReduce -= b.quantity;
|
||||
} else {
|
||||
db.prepare("UPDATE inventory_stock SET quantity = quantity - ?, updated_at = (NOW_LOCAL()) WHERE id = ?").run(neededToReduce, b.id);
|
||||
neededToReduce = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logNote = note || `Stock Opname (Penyesuaian Fisik: ${diff > 0 ? '+' : ''}${diff} ${item.unit})`;
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
|
||||
VALUES (?, NULL, 'adjust', ?, ?, ?, 'Stock Opname', 'OPNAME', ?)
|
||||
`).run(
|
||||
item_id,
|
||||
diff,
|
||||
actualQty,
|
||||
actor || 'Admin',
|
||||
logNote
|
||||
);
|
||||
|
||||
return { diff, remainingStock: actualQty };
|
||||
});
|
||||
|
||||
return run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. PENGEMBALIAN BARANG (RETURN / COPOTAN)
|
||||
* Barang bekas / copotan dari pelanggan atau sisa pemakaian lapangan dikembalikan ke gudang
|
||||
*/
|
||||
function returnStock(data, actor = 'Admin') {
|
||||
const { item_id, serial_number, quantity, condition, customer_id, returned_by, note } = data;
|
||||
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
|
||||
|
||||
const item = getItemById(item_id);
|
||||
if (!item) throw new Error('Data barang tidak ditemukan');
|
||||
|
||||
const cleanSn = serial_number ? serial_number.trim() : '';
|
||||
const qty = cleanSn ? 1 : Math.max(1, parseInt(quantity, 10) || 1);
|
||||
const cond = condition || 'used';
|
||||
|
||||
const run = db.transaction(() => {
|
||||
let stockId = null;
|
||||
|
||||
if (cleanSn) {
|
||||
const existing = db.prepare('SELECT id, status FROM inventory_stock WHERE serial_number = ?').get(cleanSn);
|
||||
if (existing) {
|
||||
// Kembalikan unit yang sudah ada ke gudang
|
||||
db.prepare(`
|
||||
UPDATE inventory_stock
|
||||
SET status = 'available', condition = ?, assigned_to_customer_id = NULL, location = 'Gudang Utama', updated_at = (NOW_LOCAL()), note = ?
|
||||
WHERE id = ?
|
||||
`).run(cond, note || 'Pengembalian / Copotan', existing.id);
|
||||
stockId = existing.id;
|
||||
} else {
|
||||
const res = db.prepare(`
|
||||
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
|
||||
VALUES (?, ?, 1, ?, 'Gudang Utama', 'available', ?)
|
||||
`).run(item_id, cleanSn, cond, note || 'Pengembalian / Copotan');
|
||||
stockId = res.lastInsertRowid;
|
||||
}
|
||||
} else {
|
||||
const res = db.prepare(`
|
||||
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
|
||||
VALUES (?, NULL, ?, ?, 'Gudang Utama', 'available', ?)
|
||||
`).run(item_id, qty, cond, note || 'Pengembalian / Copotan');
|
||||
stockId = res.lastInsertRowid;
|
||||
}
|
||||
|
||||
const remainingStock = getItemAvailableStock(item_id);
|
||||
|
||||
let returner = returned_by ? returned_by.trim() : '';
|
||||
if (customer_id && !returner) {
|
||||
const cust = db.prepare('SELECT name FROM customers WHERE id = ?').get(customer_id);
|
||||
if (cust) returner = `Copotan dari: ${cust.name}`;
|
||||
}
|
||||
if (!returner) returner = 'Teknisi Lapangan';
|
||||
|
||||
const logNote = note || (cleanSn ? `Pengembalian SN: ${cleanSn} (${cond})` : `Pengembalian (+${qty} ${item.unit})`);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
|
||||
VALUES (?, ?, 'return', ?, ?, ?, ?, 'RETURN', ?)
|
||||
`).run(
|
||||
item_id,
|
||||
stockId,
|
||||
qty,
|
||||
remainingStock,
|
||||
actor || 'Admin',
|
||||
returner,
|
||||
logNote
|
||||
);
|
||||
|
||||
return { stockId, remainingStock };
|
||||
});
|
||||
|
||||
return run();
|
||||
}
|
||||
|
||||
/**
|
||||
* 5. RIWAYAT PERGERAKAN STOK (INVENTORY LOGS)
|
||||
*/
|
||||
function getInventoryLogs(options = {}) {
|
||||
if (typeof options === 'number') options = { limit: options };
|
||||
const limit = options.limit ? parseInt(options.limit, 10) : 100;
|
||||
const itemId = options.itemId ? Number(options.itemId) : null;
|
||||
const type = options.type ? options.type.trim() : null;
|
||||
const search = options.search ? options.search.trim() : null;
|
||||
|
||||
let query = `
|
||||
SELECT l.*,
|
||||
i.name as item_name,
|
||||
i.unit as item_unit,
|
||||
i.brand as item_brand,
|
||||
i.model as item_model,
|
||||
c.name as category_name,
|
||||
s.serial_number
|
||||
FROM inventory_logs l
|
||||
LEFT JOIN inventory_items i ON l.item_id = i.id
|
||||
LEFT JOIN inventory_categories c ON i.category_id = c.id
|
||||
LEFT JOIN inventory_stock s ON l.stock_id = s.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
|
||||
if (itemId) {
|
||||
query += ' AND l.item_id = ?';
|
||||
params.push(itemId);
|
||||
}
|
||||
if (type) {
|
||||
query += ' AND l.type = ?';
|
||||
params.push(type);
|
||||
}
|
||||
if (search) {
|
||||
query += ' AND (i.name LIKE ? OR s.serial_number LIKE ? OR l.recipient LIKE ? OR l.note LIKE ? OR l.reference_no LIKE ?)';
|
||||
const s = `%${search}%`;
|
||||
params.push(s, s, s, s, s);
|
||||
}
|
||||
|
||||
query += ' ORDER BY l.created_at DESC, l.id DESC LIMIT ?';
|
||||
params.push(limit);
|
||||
|
||||
return db.prepare(query).all(...params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 6. STATISTIK GUDANG
|
||||
*/
|
||||
function getInventoryStats() {
|
||||
seedDefaultCategories();
|
||||
const totalItems = db.prepare('SELECT count(*) as c FROM inventory_items').get()?.c || 0;
|
||||
const totalCategories = db.prepare('SELECT count(*) as c FROM inventory_categories').get()?.c || 0;
|
||||
|
||||
const stockSummary = db.prepare(`
|
||||
SELECT
|
||||
COALESCE(SUM(CASE WHEN status = 'available' THEN quantity ELSE 0 END), 0) as available,
|
||||
COALESCE(SUM(CASE WHEN status = 'assigned' THEN quantity ELSE 0 END), 0) as assigned,
|
||||
COALESCE(SUM(CASE WHEN status = 'broken' THEN quantity ELSE 0 END), 0) as broken
|
||||
FROM inventory_stock
|
||||
`).get();
|
||||
|
||||
const lowStockCount = db.prepare(`
|
||||
SELECT count(*) as c FROM (
|
||||
SELECT i.id
|
||||
FROM inventory_items i
|
||||
LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available'
|
||||
GROUP BY i.id
|
||||
HAVING COALESCE(SUM(s.quantity), 0) <= i.min_stock
|
||||
)
|
||||
`).get()?.c || 0;
|
||||
|
||||
return {
|
||||
total_items: totalItems,
|
||||
total_categories: totalCategories,
|
||||
total_stock_available: Number(stockSummary?.available || 0),
|
||||
total_stock_assigned: Number(stockSummary?.assigned || 0),
|
||||
total_stock_broken: Number(stockSummary?.broken || 0),
|
||||
low_stock_count: lowStockCount
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// ─── KOMPATIBILITAS FORK ─────────────────────────────────────────────────
|
||||
function getStockByItem(itemId) {
|
||||
return db.prepare(`
|
||||
SELECT s.*, cust.name as customer_name
|
||||
@@ -83,78 +612,15 @@ function getStockByItem(itemId) {
|
||||
`).all(itemId);
|
||||
}
|
||||
|
||||
function addStock(data, actor = 'Admin') {
|
||||
const { item_id, serial_number, quantity, condition, location, note } = data;
|
||||
|
||||
const run = db.transaction(() => {
|
||||
const result = db.prepare(`
|
||||
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, note)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`).run(item_id, serial_number || null, quantity, condition || 'new', location || 'Gudang Utama', note || '');
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
|
||||
VALUES (?, ?, 'in', ?, ?, ?)
|
||||
`).run(item_id, result.lastInsertRowid, quantity, actor, note || 'Stock Masuk');
|
||||
|
||||
return result;
|
||||
});
|
||||
|
||||
return run();
|
||||
}
|
||||
|
||||
function assignStockToCustomer(stockId, customerId, actor = 'Admin', note = '') {
|
||||
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
|
||||
if (!stock) throw new Error('Stock tidak ditemukan');
|
||||
if (stock.status === 'assigned') throw new Error('Stock sudah terpasang di pelanggan lain');
|
||||
|
||||
const run = db.transaction(() => {
|
||||
db.prepare(`
|
||||
UPDATE inventory_stock
|
||||
SET status = 'assigned', assigned_to_customer_id = ?, updated_at = (NOW_LOCAL())
|
||||
WHERE id = ?
|
||||
`).run(customerId, stockId);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
|
||||
VALUES (?, ?, 'out', ?, ?, ?)
|
||||
`).run(stock.item_id, stockId, stock.quantity, actor, note || `Terpasang ke pelanggan ID: ${customerId}`);
|
||||
});
|
||||
|
||||
return run();
|
||||
}
|
||||
|
||||
function adjustStock(stockId, newQuantity, note, actor = 'Admin') {
|
||||
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
|
||||
if (!stock) throw new Error('Stock tidak ditemukan');
|
||||
|
||||
const run = db.transaction(() => {
|
||||
const diff = newQuantity - stock.quantity;
|
||||
db.prepare('UPDATE inventory_stock SET quantity = ?, updated_at = (NOW_LOCAL()) WHERE id = ?').run(newQuantity, stockId);
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
|
||||
VALUES (?, ?, 'adjust', ?, ?, ?)
|
||||
`).run(stock.item_id, stockId, diff, actor, note || 'Penyesuaian Stock');
|
||||
});
|
||||
|
||||
return run();
|
||||
}
|
||||
|
||||
function getInventoryLogs(limit = 100) {
|
||||
return db.prepare(`
|
||||
SELECT l.*, i.name as item_name, s.serial_number
|
||||
FROM inventory_logs l
|
||||
LEFT JOIN inventory_items i ON l.item_id = i.id
|
||||
LEFT JOIN inventory_stock s ON l.stock_id = s.id
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT ?
|
||||
`).all(limit);
|
||||
return issueStock({ item_id: stock.item_id, stock_id: stockId, customer_id: customerId, note }, actor);
|
||||
}
|
||||
|
||||
function getLowStockItems() {
|
||||
return db.prepare(`
|
||||
SELECT i.*, c.name as category_name, SUM(s.quantity) as current_stock
|
||||
SELECT i.*, c.name as category_name, COALESCE(SUM(s.quantity), 0) as current_stock
|
||||
FROM inventory_items i
|
||||
LEFT JOIN inventory_categories c ON i.category_id = c.id
|
||||
LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available'
|
||||
@@ -164,8 +630,11 @@ function getLowStockItems() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedDefaultCategories,
|
||||
getAllCategories, createCategory, updateCategory, deleteCategory,
|
||||
getAllItems, getItemById, createItem, updateItem, deleteItem,
|
||||
getStockByItem, addStock, assignStockToCustomer, adjustStock,
|
||||
getInventoryLogs, getLowStockItems
|
||||
getAllItems, getItemById, getItemDetail, createItem, updateItem, deleteItem,
|
||||
getAvailableSerialNumbers, getItemAvailableStock, getItemAssignedStock, getItemBrokenStock,
|
||||
addStock, issueStock, adjustStock, returnStock,
|
||||
getInventoryLogs, getInventoryStats,
|
||||
getStockByItem, assignStockToCustomer, getLowStockItems
|
||||
};
|
||||
|
||||
@@ -2128,6 +2128,44 @@ async function getBackup(routerId = null) {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ─── KOMPATIBILITAS AI / SELF-HEALING ───────────────────────────────────────
|
||||
async function getActivePppoeUsers() {
|
||||
const map = await getActivePppoeSessionsMap();
|
||||
const out = [];
|
||||
map.forEach((v, user) => out.push({
|
||||
user,
|
||||
address: v.ip || '',
|
||||
uptime: v.uptime || '',
|
||||
callerId: v.callerId || '',
|
||||
routerId: v.routerId ?? null,
|
||||
routerName: v.routerName || ''
|
||||
}));
|
||||
return out;
|
||||
}
|
||||
|
||||
async function isolateCustomerPppoe(routerId, username, opts = {}) {
|
||||
const profile = String(opts.pppoeProfile || 'ISOLIR');
|
||||
await setPppoeProfile(username, profile, routerId ?? null);
|
||||
await kickPppoeUser(username, routerId ?? null).catch(() => {});
|
||||
return true;
|
||||
}
|
||||
|
||||
async function unisolateCustomerPppoe(routerId, username) {
|
||||
const name = String(username || '').trim();
|
||||
if (!name) return false;
|
||||
const row = db.prepare(`
|
||||
SELECT p.name as package_name
|
||||
FROM customers c
|
||||
LEFT JOIN packages p ON c.package_id = p.id
|
||||
WHERE c.pppoe_username = ? COLLATE NOCASE
|
||||
LIMIT 1
|
||||
`).get(name);
|
||||
const profile = row && row.package_name ? row.package_name : 'default';
|
||||
await setPppoeProfile(name, profile, routerId ?? null);
|
||||
return true;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkConnection,
|
||||
getConnection,
|
||||
@@ -2149,6 +2187,9 @@ module.exports = {
|
||||
getHotspotProfiles,
|
||||
getPppoeActive,
|
||||
getActivePppoeSessionsMap,
|
||||
getActivePppoeUsers,
|
||||
isolateCustomerPppoe,
|
||||
unisolateCustomerPppoe,
|
||||
getAllActiveSessionsMap,
|
||||
getHotspotActive,
|
||||
getIpPools,
|
||||
|
||||
@@ -435,6 +435,67 @@ function getAvailablePeriods() {
|
||||
`).all();
|
||||
}
|
||||
|
||||
|
||||
function updateSlip(id, data) {
|
||||
const slip = getSlipById(id);
|
||||
if (!slip) throw new Error('Slip tidak ditemukan');
|
||||
if (slip.status !== 'draft') throw new Error('Hanya slip berstatus draft yang dapat diedit');
|
||||
|
||||
const pickInt = (key, fallback) => (data[key] !== undefined && data[key] !== '') ? parseInt(data[key], 10) || 0 : fallback;
|
||||
const pickFloat = (key, fallback) => (data[key] !== undefined && data[key] !== '') ? parseFloat(data[key]) || 0 : fallback;
|
||||
|
||||
const baseSalary = pickInt('base_salary', slip.base_salary);
|
||||
const transportAllowance = pickInt('transport_allowance', slip.transport_allowance);
|
||||
const mealAllowance = pickInt('meal_allowance', slip.meal_allowance);
|
||||
const phoneAllowance = pickInt('phone_allowance', slip.phone_allowance);
|
||||
const otherAllowance = pickInt('other_allowance', slip.other_allowance);
|
||||
const otherAllowanceNote = data.other_allowance_note !== undefined ? String(data.other_allowance_note) : slip.other_allowance_note;
|
||||
|
||||
const workingDays = pickInt('working_days', slip.working_days);
|
||||
const absentDays = pickInt('absent_days', slip.absent_days);
|
||||
const lateDays = pickInt('late_days', slip.late_days);
|
||||
const overtimeHours = pickFloat('overtime_hours', slip.overtime_hours);
|
||||
const overtimeBonus = pickInt('overtime_bonus', slip.overtime_bonus);
|
||||
const ticketBonus = pickInt('ticket_bonus', slip.ticket_bonus);
|
||||
const collectionCommission = pickInt('collection_commission', slip.collection_commission);
|
||||
|
||||
const absenceDeduction = pickInt('absence_deduction', slip.absence_deduction);
|
||||
const lateDeduction = pickInt('late_deduction', slip.late_deduction);
|
||||
const otherDeduction = pickInt('other_deduction', slip.other_deduction);
|
||||
const otherDeductionNote = data.other_deduction_note !== undefined ? String(data.other_deduction_note) : slip.other_deduction_note;
|
||||
const notes = data.notes !== undefined ? String(data.notes) : slip.notes;
|
||||
|
||||
const grossSalary = baseSalary + transportAllowance + mealAllowance + phoneAllowance + otherAllowance + ticketBonus + collectionCommission + overtimeBonus;
|
||||
const totalDeductions = absenceDeduction + lateDeduction + otherDeduction;
|
||||
const netSalary = Math.max(0, grossSalary - totalDeductions);
|
||||
|
||||
db.prepare(`
|
||||
UPDATE payroll_slips SET
|
||||
base_salary = ?, transport_allowance = ?, meal_allowance = ?,
|
||||
phone_allowance = ?, other_allowance = ?, other_allowance_note = ?,
|
||||
working_days = ?, absent_days = ?, late_days = ?,
|
||||
overtime_hours = ?, overtime_bonus = ?,
|
||||
ticket_bonus = ?, collection_commission = ?,
|
||||
absence_deduction = ?, late_deduction = ?,
|
||||
other_deduction = ?, other_deduction_note = ?,
|
||||
gross_salary = ?, total_deductions = ?, net_salary = ?,
|
||||
notes = ?
|
||||
WHERE id = ?
|
||||
`).run(
|
||||
baseSalary, transportAllowance, mealAllowance,
|
||||
phoneAllowance, otherAllowance, otherAllowanceNote || '',
|
||||
workingDays, absentDays, lateDays,
|
||||
overtimeHours, overtimeBonus,
|
||||
ticketBonus, collectionCommission,
|
||||
absenceDeduction, lateDeduction,
|
||||
otherDeduction, otherDeductionNote || '',
|
||||
grossSalary, totalDeductions, netSalary,
|
||||
notes || '', id
|
||||
);
|
||||
|
||||
return getSlipById(id);
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EMPLOYEE_TYPES,
|
||||
getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting,
|
||||
@@ -442,7 +503,7 @@ module.exports = {
|
||||
getTicketsResolvedCount, getCollectionAmount, getAttendanceData,
|
||||
generateSlip, generateAllSlips,
|
||||
getSlipById, getSlipsByPeriod, getSlipsByEmployee,
|
||||
updateSlipDeductions, approveSlip, markSlipPaid,
|
||||
updateSlipDeductions, updateSlip, approveSlip, markSlipPaid,
|
||||
bulkApprove, bulkMarkPaid,
|
||||
deleteSlip, deleteSlipsByPeriod,
|
||||
getPayrollSummary, getAvailablePeriods
|
||||
|
||||
369
services/securityWatchdogService.js
Normal file
@@ -0,0 +1,369 @@
|
||||
/**
|
||||
* Service: AI Security & Brute-Force Anomaly Watchdog
|
||||
* Mendeteksi anomali serangan brute force pada aplikasi billing dan router MikroTik.
|
||||
* Mengumumkan peringatan darurat secara otomatis melalui Voice TTS (Soundbox)
|
||||
* serta mengirim notifikasi siaga ke WhatsApp & Telegram Admin.
|
||||
*/
|
||||
const db = require('../config/database');
|
||||
const { logger } = require('../config/logger');
|
||||
const soundboxSvc = require('./soundboxService');
|
||||
const { getSetting } = require('../config/settingsManager');
|
||||
|
||||
// In-memory state tracking
|
||||
const failedAttemptsByIp = new Map();
|
||||
const blockedIps = new Map(); // ip -> unblockTime
|
||||
const mikrotikAlertCache = new Map(); // routerId:ip -> timestamp
|
||||
const processedLogIdsByRouter = new Map(); // routerId -> Set of log IDs
|
||||
|
||||
const WINDOW_MS = 5 * 60 * 1000; // 5 menit
|
||||
const BLOCK_DURATION_MS = 15 * 60 * 1000; // 15 menit
|
||||
const WARNING_THRESHOLD = 3;
|
||||
const CRITICAL_THRESHOLD = 5;
|
||||
|
||||
/**
|
||||
* Mengonversi IP address menjadi format lisan ramah suara TTS
|
||||
* Contoh: "192.168.8.50" -> "1 9 2 titik 1 6 8 titik 8 titik 5 0"
|
||||
*/
|
||||
function formatIpForSpeech(ip) {
|
||||
if (!ip) return 'tidak diketahui';
|
||||
const cleanIp = String(ip).replace(/^::ffff:/, '');
|
||||
return cleanIp.split('.').map(octet => octet.split('').join(' ')).join(' titik ');
|
||||
}
|
||||
|
||||
class SecurityWatchdogService {
|
||||
/**
|
||||
* Cek apakah IP sedang dalam masa blokir sementara
|
||||
*/
|
||||
isIpBlocked(rawIp) {
|
||||
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
|
||||
if (!ip) return false;
|
||||
|
||||
const unblockTime = blockedIps.get(ip);
|
||||
if (unblockTime) {
|
||||
if (Date.now() < unblockTime) {
|
||||
return true;
|
||||
} else {
|
||||
blockedIps.delete(ip);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Buka blokir IP secara manual
|
||||
*/
|
||||
unblockIp(rawIp) {
|
||||
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
|
||||
if (!ip) return false;
|
||||
blockedIps.delete(ip);
|
||||
failedAttemptsByIp.delete(ip);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mencatat percobaan login pada aplikasi (Admin / Kasir / Agent / Tech)
|
||||
*/
|
||||
async recordAppLoginAttempt({ type = 'admin', username = '', ip: rawIp = '127.0.0.1', success = false, userAgent = '' }) {
|
||||
const ip = String(rawIp || '').replace(/^::ffff:/, '').trim();
|
||||
const now = Date.now();
|
||||
|
||||
// Jika login berhasil, reset kegagalan IP ini
|
||||
if (success) {
|
||||
failedAttemptsByIp.delete(ip);
|
||||
return { allowed: true };
|
||||
}
|
||||
|
||||
// Login Gagal: Ambil atau buat record tracking
|
||||
let record = failedAttemptsByIp.get(ip);
|
||||
if (!record || (now - record.lastAttempt > WINDOW_MS)) {
|
||||
record = {
|
||||
ip,
|
||||
count: 1,
|
||||
firstAttempt: now,
|
||||
lastAttempt: now,
|
||||
usernames: new Set([username]),
|
||||
alerted: false
|
||||
};
|
||||
} else {
|
||||
record.count++;
|
||||
record.lastAttempt = now;
|
||||
if (username) record.usernames.add(username);
|
||||
}
|
||||
failedAttemptsByIp.set(ip, record);
|
||||
|
||||
logger.warn(`[SecurityWatchdog] Login gagal ke ${type} dari IP ${ip} (Percobaan ke-${record.count}, target: ${username})`);
|
||||
|
||||
// Cek Ambang Batas Kritis (Brute Force Detected)
|
||||
if (record.count >= CRITICAL_THRESHOLD && !record.alerted) {
|
||||
record.alerted = true;
|
||||
blockedIps.set(ip, now + BLOCK_DURATION_MS);
|
||||
|
||||
const targetList = Array.from(record.usernames).filter(Boolean).join(', ') || 'admin';
|
||||
const ipSpoken = formatIpForSpeech(ip);
|
||||
|
||||
// 1. ANNOUNCEMENT VOICE VIA SOUNDBOX (SPEAKER KASIR / ADMIN)
|
||||
const speechText = `Peringatan keamanan! Terdeteksi anomali percobaan login gagal berulang kali ke aplikasi ${type} dari alamat i pe ${ipSpoken}. Sistem telah memblokir sementara akses tersebut.`;
|
||||
|
||||
soundboxSvc.queueSoundboxEvent({
|
||||
type: 'security_alert',
|
||||
title: 'PERINGATAN BRUTE FORCE APLIKASI',
|
||||
speechText: speechText,
|
||||
amount: 0,
|
||||
customerName: `IP: ${ip} (Target: ${targetList})`
|
||||
});
|
||||
|
||||
// 2. KIRIM NOTIFIKASI SIAGA KE WHATSAPP ADMIN
|
||||
const waMsg =
|
||||
`🚨 *PERINGATAN KEAMANAN (BRUTE FORCE DETECTED)*\n\n` +
|
||||
`Sistem mendeteksi percobaan login gagal berulang kali ke portal *${type.toUpperCase()}*:\n` +
|
||||
`🌐 *Alamat IP:* \`${ip}\`\n` +
|
||||
`👤 *Target Username:* ${targetList}\n` +
|
||||
`📊 *Jumlah Kegagalan:* ${record.count} kali dalam 5 menit\n` +
|
||||
`⏱️ *Waktu:* ${new Date().toLocaleString('id-ID')}\n` +
|
||||
`🛡️ *Status:* IP telah diblokir sementara selama 15 menit.\n\n` +
|
||||
`_Peringatan suara telah dibunyikan otomatis melalui Soundbox._`;
|
||||
|
||||
await this._notifyAdmin(waMsg);
|
||||
|
||||
// 3. CATAT KE AUDIT TRAIL
|
||||
this._recordAudit({
|
||||
action: 'BRUTE_FORCE_BLOCKED',
|
||||
entity_type: 'app_login',
|
||||
entity_id: ip,
|
||||
details: {
|
||||
portal: type,
|
||||
failedCount: record.count,
|
||||
targets: Array.from(record.usernames),
|
||||
userAgent
|
||||
},
|
||||
ip_address: ip,
|
||||
user_agent: userAgent
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: !this.isIpBlocked(ip),
|
||||
attempts: record.count,
|
||||
remaining: Math.max(0, CRITICAL_THRESHOLD - record.count)
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Pindai log MikroTik untuk mendeteksi brute-force login SSH / Telnet / Winbox / WebFig
|
||||
*/
|
||||
async scanMikrotikLoginFailures() {
|
||||
try {
|
||||
const mt = require('./mikrotikService');
|
||||
const routers = db.prepare("SELECT id, name, host FROM routers WHERE is_active = 1").all() || [];
|
||||
|
||||
for (const r of routers) {
|
||||
try {
|
||||
const conn = await mt.getConnection(r.id);
|
||||
if (!conn || !conn.client) continue;
|
||||
|
||||
// Ambil log dari MikroTik
|
||||
const logs = await conn.client.menu('/log').get();
|
||||
if (!Array.isArray(logs) || logs.length === 0) continue;
|
||||
|
||||
// Ambil atau buat set log ID yang sudah diproses agar tidak duplikat
|
||||
let processedSet = processedLogIdsByRouter.get(r.id);
|
||||
if (!processedSet) {
|
||||
processedSet = new Set();
|
||||
processedLogIdsByRouter.set(r.id, processedSet);
|
||||
}
|
||||
|
||||
// Filter log kegagalan login
|
||||
const failures = [];
|
||||
for (const l of logs) {
|
||||
const logId = l['.id'] || l.id;
|
||||
if (processedSet.has(logId)) continue;
|
||||
processedSet.add(logId);
|
||||
|
||||
const msg = String(l.message || '');
|
||||
if (msg.toLowerCase().includes('login failure') || msg.toLowerCase().includes('failure')) {
|
||||
failures.push(l);
|
||||
}
|
||||
}
|
||||
|
||||
// Bersihkan cache log ID jika terlalu besar
|
||||
if (processedSet.size > 2000) {
|
||||
processedSet.clear();
|
||||
}
|
||||
|
||||
if (failures.length === 0) continue;
|
||||
|
||||
// Kelompokkan kegagalan berdasarkan IP penyerang
|
||||
const failuresByIp = new Map();
|
||||
for (const f of failures) {
|
||||
const msg = f.message || '';
|
||||
// Regex MikroTik format: "login failure for user admin from 192.168.1.50 via winbox"
|
||||
const match = msg.match(/login failure for user\s+(\S+)\s+from\s+([0-9a-fA-F.:]+)(?:\s+via\s+(\S+))?/i) ||
|
||||
msg.match(/failure.*user\s+(\S+).*from\s+([0-9a-fA-F.:]+)/i);
|
||||
|
||||
if (match) {
|
||||
const targetUser = match[1] || 'unknown';
|
||||
const attackerIp = String(match[2] || '').replace(/^::ffff:/, '').trim();
|
||||
const service = match[3] || 'router-service';
|
||||
|
||||
if (!attackerIp || attackerIp === '127.0.0.1' || attackerIp === 'localhost') continue;
|
||||
|
||||
if (!failuresByIp.has(attackerIp)) {
|
||||
failuresByIp.set(attackerIp, { count: 1, targets: new Set([targetUser]), service });
|
||||
} else {
|
||||
const item = failuresByIp.get(attackerIp);
|
||||
item.count++;
|
||||
item.targets.add(targetUser);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Periksa setiap penyerang
|
||||
const now = Date.now();
|
||||
for (const [attackerIp, data] of failuresByIp.entries()) {
|
||||
if (data.count >= 2) {
|
||||
const cacheKey = `${r.id}:${attackerIp}`;
|
||||
const lastAlert = mikrotikAlertCache.get(cacheKey) || 0;
|
||||
|
||||
// Debounce alert per IP router tiap 15 menit
|
||||
if (now - lastAlert > 15 * 60 * 1000) {
|
||||
mikrotikAlertCache.set(cacheKey, now);
|
||||
|
||||
const targetsStr = Array.from(data.targets).join(', ');
|
||||
const ipSpoken = formatIpForSpeech(attackerIp);
|
||||
|
||||
logger.warn(`[SecurityWatchdog] Terdeteksi brute-force MikroTik '${r.name}' dari ${attackerIp} (${data.count}x via ${data.service})`);
|
||||
|
||||
// 1. ANNOUNCEMENT VOICE VIA SOUNDBOX (SPEAKER KASIR / ADMIN)
|
||||
const speechText = `Peringatan darurat! Terdeteksi serangan brute force ke router MikroTik ${r.name} dari alamat i pe ${ipSpoken} melalui ${data.service}. Segera periksa keamanan router Anda.`;
|
||||
|
||||
soundboxSvc.queueSoundboxEvent({
|
||||
type: 'security_alert',
|
||||
title: 'PERINGATAN BRUTE FORCE MIKROTIK',
|
||||
speechText: speechText,
|
||||
amount: 0,
|
||||
customerName: `Router: ${r.name} | IP: ${attackerIp}`
|
||||
});
|
||||
|
||||
// 2. KIRIM NOTIFIKASI SIAGA KE WHATSAPP ADMIN
|
||||
const waMsg =
|
||||
`🚨 *PERINGATAN KEAMANAN (SERANGAN MIKROTIK)*\n\n` +
|
||||
`Terdeteksi serangan brute force login pada router *${r.name}* (${r.host}):\n` +
|
||||
`🌐 *IP Penyerang:* \`${attackerIp}\`\n` +
|
||||
`🔌 *Layanan:* ${data.service.toUpperCase()}\n` +
|
||||
`👤 *Target User:* ${targetsStr}\n` +
|
||||
`📊 *Jumlah Percobaan Gagal:* ${data.count} kali\n` +
|
||||
`⏱️ *Waktu:* ${new Date().toLocaleString('id-ID')}\n\n` +
|
||||
`_Peringatan suara darurat telah dibunyikan melalui Soundbox._`;
|
||||
|
||||
await this._notifyAdmin(waMsg);
|
||||
|
||||
// 3. CATAT KE AUDIT TRAIL
|
||||
this._recordAudit({
|
||||
action: 'ROUTER_BRUTE_FORCE',
|
||||
entity_type: 'mikrotik_login',
|
||||
entity_id: `${r.name}:${attackerIp}`,
|
||||
details: {
|
||||
router: r.name,
|
||||
routerHost: r.host,
|
||||
attackerIp,
|
||||
service: data.service,
|
||||
count: data.count,
|
||||
targets: Array.from(data.targets)
|
||||
},
|
||||
ip_address: attackerIp
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[SecurityWatchdog] Scan router ${r.name} error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`[SecurityWatchdog] Scan MikroTik failures fatal: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ringkasan status keamanan saat ini
|
||||
*/
|
||||
getSecurityOverview() {
|
||||
const blockedList = [];
|
||||
const now = Date.now();
|
||||
for (const [ip, unblockTime] of blockedIps.entries()) {
|
||||
if (now < unblockTime) {
|
||||
blockedList.push({ ip, remainingSeconds: Math.ceil((unblockTime - now) / 1000) });
|
||||
}
|
||||
}
|
||||
|
||||
let recentAudit = [];
|
||||
try {
|
||||
recentAudit = db.prepare(`
|
||||
SELECT * FROM audit_trail
|
||||
WHERE action IN ('BRUTE_FORCE_BLOCKED', 'ROUTER_BRUTE_FORCE')
|
||||
ORDER BY id DESC LIMIT 10
|
||||
`).all() || [];
|
||||
} catch (_) {}
|
||||
|
||||
return {
|
||||
status: blockedList.length > 0 ? 'warning' : 'secure',
|
||||
activeBlockedIps: blockedList,
|
||||
recentAlerts: recentAudit,
|
||||
timestamp: new Date().toISOString()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Kirim notifikasi ke WhatsApp dan Telegram Admin
|
||||
*/
|
||||
async _notifyAdmin(message) {
|
||||
try {
|
||||
const adminPhone = getSetting('admin_phone') || getSetting('company_phone');
|
||||
if (adminPhone) {
|
||||
const { sendWA, whatsappStatus } = await import('./whatsappBot.mjs');
|
||||
if (whatsappStatus && whatsappStatus.connection === 'open') {
|
||||
await sendWA(adminPhone, message);
|
||||
}
|
||||
}
|
||||
|
||||
// Telegram Admin Notification
|
||||
const adminChatId = getSetting('telegram_admin_chat_id') || getSetting('telegram_chat_id');
|
||||
const botToken = getSetting('telegram_bot_token');
|
||||
if (adminChatId && botToken) {
|
||||
const axios = require('axios');
|
||||
await axios.post(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||
chat_id: adminChatId,
|
||||
text: message,
|
||||
parse_mode: 'Markdown'
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[SecurityWatchdog] Gagal kirim notifikasi siaga: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper: Catat audit log
|
||||
*/
|
||||
_recordAudit({ action, entity_type, entity_id, details, ip_address, user_agent }) {
|
||||
try {
|
||||
const audit = require('./auditTrailService');
|
||||
if (audit && typeof audit.logAuditTrail === 'function') {
|
||||
audit.logAuditTrail({
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
actor_type: 'system',
|
||||
actor_id: 'security-watchdog',
|
||||
actor_name: 'AI Security Watchdog',
|
||||
details,
|
||||
ip_address,
|
||||
user_agent
|
||||
});
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
const securityWatchdogService = new SecurityWatchdogService();
|
||||
module.exports = securityWatchdogService;
|
||||
346
services/selfHealingService.js
Normal file
@@ -0,0 +1,346 @@
|
||||
/**
|
||||
* Service: Autonomous Self-Healing & Network Auto-Repair Engine
|
||||
* Memindai anomali dan secara otomatis memperbaiki ketidaksesuaian antara
|
||||
* Billing System, Router MikroTik, GenieACS TR-069, dan RADIUS Server.
|
||||
*/
|
||||
const db = require('../config/database');
|
||||
const mikrotikService = require('./mikrotikService');
|
||||
const billingSvc = require('./billingService');
|
||||
const customerSvc = require('./customerService');
|
||||
const customerDevice = require('./customerDeviceService');
|
||||
const { logger } = require('../config/logger');
|
||||
let auditSvc = null;
|
||||
try { auditSvc = require('./auditTrailService'); } catch (_) {}
|
||||
let radiusSvc = null;
|
||||
try { radiusSvc = require('./radiusServerService'); } catch (_) {}
|
||||
|
||||
class SelfHealingService {
|
||||
/**
|
||||
* Jalankan diagnosa lengkap tanpa eksekusi perbaikan (Dry Run / Scan Mode)
|
||||
*/
|
||||
async diagnose() {
|
||||
return this.run({ autoRepair: false });
|
||||
}
|
||||
|
||||
/**
|
||||
* Jalankan diagnosa DAN perbaikan otomatis (Healing Mode)
|
||||
*/
|
||||
async autoRepair(actorName = 'AI Self-Healing Agent') {
|
||||
return this.run({ autoRepair: true, actorName });
|
||||
}
|
||||
|
||||
/**
|
||||
* Mesin Utama Pemindaian & Perbaikan
|
||||
*/
|
||||
async run({ autoRepair = true, actorName = 'System' } = {}) {
|
||||
const startTime = Date.now();
|
||||
const result = {
|
||||
timestamp: new Date().toISOString(),
|
||||
mode: autoRepair ? 'repair' : 'scan_only',
|
||||
actor: actorName,
|
||||
stats: {
|
||||
customersChecked: 0,
|
||||
routersChecked: 0,
|
||||
anomaliesFound: 0,
|
||||
anomaliesRepaired: 0,
|
||||
failedRepairs: 0
|
||||
},
|
||||
repairs: [],
|
||||
issues: [],
|
||||
warnings: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
try {
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 1. SINKRONISASI KONSISTENSI STATUS BILLING VS TAGIHAN PELANGGAN
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
await this._healBillingCustomerStatus(result, autoRepair);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 2. SINKRONISASI MIKROTIK (PPPoE SECRETS, ISOLIR, FIREWALL)
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
await this._healMikrotikConsistency(result, autoRepair);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 3. PEMBERSIHAN SESI RADIUS YANG STALE / KADALUARSA
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
await this._healRadiusSessions(result, autoRepair);
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
// 4. SINKRONISASI GENIEACS & TR-069 MODEM ONU
|
||||
// ─────────────────────────────────────────────────────────────────
|
||||
await this._healAcsDevices(result, autoRepair);
|
||||
|
||||
result.durationMs = Date.now() - startTime;
|
||||
|
||||
// Catat di Audit Trail jika ada aksi perbaikan yang dieksekusi
|
||||
if (autoRepair && result.repairs.length > 0 && auditSvc && typeof auditSvc.logAuditTrail === 'function') {
|
||||
try {
|
||||
auditSvc.logAuditTrail({
|
||||
action: 'UPDATE',
|
||||
entity_type: 'self_healing',
|
||||
entity_id: 'network_auto_repair',
|
||||
actor_type: 'system',
|
||||
actor_id: 'ai-agent',
|
||||
actor_name: actorName,
|
||||
details: {
|
||||
repairedCount: result.stats.anomaliesRepaired,
|
||||
repairs: result.repairs,
|
||||
issues: result.issues
|
||||
}
|
||||
});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
logger.info(`[SelfHealing] Pemindaian selesai dalam ${result.durationMs}ms. Ditemukan: ${result.stats.anomaliesFound}, Diperbaiki: ${result.stats.anomaliesRepaired}`);
|
||||
} catch (err) {
|
||||
logger.error(`[SelfHealing] Fatal error during self-healing: ${err.message}`);
|
||||
result.errors.push(`Fatal error: ${err.message}`);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 1. Perbaiki Inkonsistensi Status Billing Pelanggan
|
||||
* - Pelanggan berstatus isolir/suspended padahal semua tagihannya sudah lunas -> Buka Isolir
|
||||
*/
|
||||
async _healBillingCustomerStatus(result, autoRepair) {
|
||||
try {
|
||||
// Cari pelanggan yang statusnya isolated/suspended tetapi TIDAK punya invoice unpaid
|
||||
const query = `
|
||||
SELECT c.id, c.name, c.status, c.pppoe_username, c.router_id,
|
||||
COUNT(CASE WHEN i.status != 'paid' THEN 1 END) as unpaid_count
|
||||
FROM customers c
|
||||
LEFT JOIN invoices i ON c.id = i.customer_id
|
||||
WHERE LOWER(c.status) IN ('isolated', 'suspended', 'isolir')
|
||||
GROUP BY c.id
|
||||
HAVING unpaid_count = 0
|
||||
`;
|
||||
const falseIsolated = db.prepare(query).all() || [];
|
||||
|
||||
for (const cust of falseIsolated) {
|
||||
result.stats.anomaliesFound++;
|
||||
const desc = `Pelanggan #${cust.id} (${cust.name}) berstatus '${cust.status}' padahal seluruh tagihannya telah LUNAS.`;
|
||||
result.issues.push({ type: 'false_isolation', customerId: cust.id, name: cust.name, description: desc });
|
||||
|
||||
if (autoRepair) {
|
||||
try {
|
||||
// Ubah status ke active di database
|
||||
db.prepare("UPDATE customers SET status = 'active', updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(cust.id);
|
||||
// Eksekusi buka isolir di MikroTik jika terhubung
|
||||
if (cust.router_id && cust.pppoe_username) {
|
||||
await mikrotikService.unisolateCustomerPppoe(cust.router_id, cust.pppoe_username).catch(() => {});
|
||||
}
|
||||
result.stats.anomaliesRepaired++;
|
||||
result.repairs.push(`[Billing] Buka isolir & aktifkan pelanggan #${cust.id} (${cust.name}) karena tagihan sudah lunas.`);
|
||||
} catch (e) {
|
||||
result.stats.failedRepairs++;
|
||||
result.errors.push(`Gagal memulihkan status pelanggan #${cust.id}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`[SelfHealing] Error healing billing status: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 2. Perbaiki Inkonsistensi MikroTik (Secrets, Profiles, Address-Lists)
|
||||
*/
|
||||
async _healMikrotikConsistency(result, autoRepair) {
|
||||
try {
|
||||
const routers = db.prepare("SELECT * FROM routers WHERE is_active = 1").all() || [];
|
||||
result.stats.routersChecked = routers.length;
|
||||
|
||||
for (const r of routers) {
|
||||
let isOnline = false;
|
||||
try {
|
||||
isOnline = await mikrotikService.checkConnection(r.id);
|
||||
} catch (_) {}
|
||||
|
||||
if (!isOnline) {
|
||||
result.warnings.push(`Router "${r.name}" (${r.host}) offline atau API port tidak merespons.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ambil data secrets dan active sessions dari router
|
||||
let secrets = [];
|
||||
try {
|
||||
secrets = await mikrotikService.getPppoeSecrets(r.id) || [];
|
||||
} catch (e) {
|
||||
result.warnings.push(`Gagal membaca PPP Secrets dari router "${r.name}": ${e.message}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const secretMap = new Map();
|
||||
for (const s of secrets) {
|
||||
if (s.name) secretMap.set(s.name.toLowerCase(), s);
|
||||
}
|
||||
|
||||
// Ambil daftar pelanggan yang dialokasikan ke router ini
|
||||
const customers = db.prepare(`
|
||||
SELECT c.*, p.name as package_name, p.billing_type as package_billing_type
|
||||
FROM customers c
|
||||
LEFT JOIN packages p ON c.package_id = p.id
|
||||
WHERE c.router_id = ? AND c.pppoe_username IS NOT NULL AND c.pppoe_username != ''
|
||||
`).all(r.id) || [];
|
||||
|
||||
result.stats.customersChecked += customers.length;
|
||||
|
||||
for (const cust of customers) {
|
||||
const userLower = (cust.pppoe_username || '').trim().toLowerCase();
|
||||
if (!userLower) continue;
|
||||
|
||||
const sec = secretMap.get(userLower);
|
||||
const isActiveCustomer = String(cust.status || '').toLowerCase() === 'active';
|
||||
const isIsolatedCustomer = ['isolated', 'suspended', 'isolir'].includes(String(cust.status || '').toLowerCase());
|
||||
const targetProfile = cust.package_name || 'default';
|
||||
|
||||
// KASUS A: Pelanggan Aktif, tetapi Secret Belum Dibuat di MikroTik
|
||||
if (isActiveCustomer && !sec) {
|
||||
result.stats.anomaliesFound++;
|
||||
const desc = `Pelanggan aktif #${cust.id} (${cust.name}) belum memiliki Secret PPPoE '${cust.pppoe_username}' di router "${r.name}".`;
|
||||
result.issues.push({ type: 'missing_secret', customerId: cust.id, username: cust.pppoe_username, description: desc });
|
||||
|
||||
if (autoRepair) {
|
||||
try {
|
||||
await mikrotikService.createPppoeSecret({
|
||||
username: cust.pppoe_username,
|
||||
password: cust.pppoe_password || cust.pppoe_username,
|
||||
profile: targetProfile,
|
||||
routerId: r.id
|
||||
});
|
||||
result.stats.anomaliesRepaired++;
|
||||
result.repairs.push(`[MikroTik] Dibuatkan Secret PPPoE baru '${cust.pppoe_username}' (Profile: ${targetProfile}) di router "${r.name}".`);
|
||||
} catch (e) {
|
||||
result.stats.failedRepairs++;
|
||||
result.errors.push(`Gagal membuat secret '${cust.pppoe_username}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KASUS B: Pelanggan Aktif, tetapi Secret di MikroTik Disabled atau Masih Berprofil ISOLIR
|
||||
else if (isActiveCustomer && sec) {
|
||||
const isSecDisabled = sec.disabled === 'true' || sec.disabled === true;
|
||||
const isSecIsolirProfile = String(sec.profile || '').toUpperCase().includes('ISOLIR');
|
||||
|
||||
if (isSecDisabled || isSecIsolirProfile) {
|
||||
result.stats.anomaliesFound++;
|
||||
const desc = `Pelanggan aktif #${cust.id} (${cust.name}) di MikroTik berstatus disabled (${isSecDisabled}) atau profil masih '${sec.profile}'.`;
|
||||
result.issues.push({ type: 'active_but_blocked_secret', customerId: cust.id, username: cust.pppoe_username, description: desc });
|
||||
|
||||
if (autoRepair) {
|
||||
try {
|
||||
await mikrotikService.updatePppoeSecret({
|
||||
username: cust.pppoe_username,
|
||||
password: cust.pppoe_password || sec.password,
|
||||
profile: targetProfile,
|
||||
disabled: false,
|
||||
routerId: r.id
|
||||
});
|
||||
// Buka juga dari firewall isolir list jika ada
|
||||
await mikrotikService.unisolateCustomerPppoe(r.id, cust.pppoe_username).catch(() => {});
|
||||
result.stats.anomaliesRepaired++;
|
||||
result.repairs.push(`[MikroTik] Mengaktifkan kembali Secret '${cust.pppoe_username}' ke profil '${targetProfile}' di router "${r.name}".`);
|
||||
} catch (e) {
|
||||
result.stats.failedRepairs++;
|
||||
result.errors.push(`Gagal memulihkan secret '${cust.pppoe_username}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// KASUS C: Pelanggan Terisolir di Billing, tetapi di MikroTik Masih Aktif / Profil Normal
|
||||
else if (isIsolatedCustomer && sec) {
|
||||
const isSecDisabled = sec.disabled === 'true' || sec.disabled === true;
|
||||
const isSecIsolirProfile = String(sec.profile || '').toUpperCase().includes('ISOLIR');
|
||||
|
||||
if (!isSecDisabled && !isSecIsolirProfile) {
|
||||
result.stats.anomaliesFound++;
|
||||
const desc = `Pelanggan terisolir #${cust.id} (${cust.name}) di MikroTik masih aktif dengan profil normal '${sec.profile}'.`;
|
||||
result.issues.push({ type: 'isolated_not_enforced', customerId: cust.id, username: cust.pppoe_username, description: desc });
|
||||
|
||||
if (autoRepair) {
|
||||
try {
|
||||
await mikrotikService.isolateCustomerPppoe(r.id, cust.pppoe_username, { pppoeProfile: 'ISOLIR' });
|
||||
await mikrotikService.kickPppoeUser(cust.pppoe_username, r.id).catch(() => {});
|
||||
result.stats.anomaliesRepaired++;
|
||||
result.repairs.push(`[MikroTik] Menerapkan isolir & kick sesi '${cust.pppoe_username}' di router "${r.name}".`);
|
||||
} catch (e) {
|
||||
result.stats.failedRepairs++;
|
||||
result.errors.push(`Gagal menerapkan isolir '${cust.pppoe_username}': ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error(`[SelfHealing] Error healing MikroTik consistency: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 3. Bersihkan Sesi RADIUS yang Menggantung (Zombie / Stale)
|
||||
*/
|
||||
async _healRadiusSessions(result, autoRepair) {
|
||||
try {
|
||||
if (!radiusSvc) return;
|
||||
|
||||
const onlineCountBefore = (radiusSvc.getOnlineSessions() || []).length;
|
||||
if (autoRepair && typeof radiusSvc.cleanupStaleSessions === 'function') {
|
||||
const cleaned = await radiusSvc.cleanupStaleSessions();
|
||||
if (cleaned > 0) {
|
||||
result.stats.anomaliesFound += cleaned;
|
||||
result.stats.anomaliesRepaired += cleaned;
|
||||
result.repairs.push(`[RADIUS Server] Membersihkan ${cleaned} sesi accounting yang menggantung (stale).`);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`[SelfHealing] RADIUS session check: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 4. Sinkronisasi Perangkat ACS TR-069
|
||||
*/
|
||||
async _healAcsDevices(result, autoRepair) {
|
||||
try {
|
||||
const count = db.prepare("SELECT COUNT(*) as c FROM acs_devices WHERE ip_address IS NULL OR ip_address = ''").get();
|
||||
if (count && count.c > 0) {
|
||||
result.stats.anomaliesFound += count.c;
|
||||
if (autoRepair) {
|
||||
// Trigger update IP dari MikroTik active sessions
|
||||
const activeSessionsMap = await mikrotikService.getActivePppoeSessionsMap().catch(() => new Map());
|
||||
if (activeSessionsMap && activeSessionsMap.size > 0) {
|
||||
let fixed = 0;
|
||||
const devs = db.prepare("SELECT id, params FROM acs_devices WHERE ip_address IS NULL OR ip_address = ''").all() || [];
|
||||
for (const d of devs) {
|
||||
let p = {};
|
||||
try { p = JSON.parse(d.params || '{}'); } catch (_) {}
|
||||
const user = p['VirtualParameters.pppoeUsername'] || p['Device.PPP.Interface.1.Username'];
|
||||
if (user && activeSessionsMap.has(user.toLowerCase())) {
|
||||
const ip = activeSessionsMap.get(user.toLowerCase()).ip;
|
||||
if (ip) {
|
||||
db.prepare("UPDATE acs_devices SET ip_address = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?").run(ip, d.id);
|
||||
fixed++;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fixed > 0) {
|
||||
result.stats.anomaliesRepaired += fixed;
|
||||
result.repairs.push(`[ACS TR-069] Memperbarui IP address untuk ${fixed} modem ONU dari sesi aktif MikroTik.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
logger.warn(`[SelfHealing] ACS check: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selfHealingService = new SelfHealingService();
|
||||
module.exports = selfHealingService;
|
||||
132
services/sessionLimitService.js
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Service: batas jumlah sesi login per akun.
|
||||
*
|
||||
* - admin / kasir / teknisi / kolektor : maksimal 2 sesi aktif per akun
|
||||
* - pelanggan : maksimal 3 sesi aktif per akun
|
||||
*
|
||||
* Sesi yang melebihi batas akan dihapus dari tabel `sessions` (cookie lama
|
||||
* otomatis tidak berlaku), bukan sekadar diabaikan. Rotasi session id saat
|
||||
* login mencegah session fixation.
|
||||
*/
|
||||
const db = require('../config/database');
|
||||
const { logger } = require('../config/logger');
|
||||
|
||||
const DEFAULT_LIMITS = {
|
||||
admin: 2,
|
||||
cashier: 2,
|
||||
technician: 2,
|
||||
collector: 2,
|
||||
customer: 3
|
||||
};
|
||||
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS login_sessions (
|
||||
sid TEXT PRIMARY KEY,
|
||||
role TEXT NOT NULL,
|
||||
account_id TEXT NOT NULL,
|
||||
ip TEXT DEFAULT '',
|
||||
user_agent TEXT DEFAULT '',
|
||||
created_at INTEGER NOT NULL,
|
||||
last_seen INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_login_sessions_account ON login_sessions(role, account_id);
|
||||
`);
|
||||
} catch (e) {
|
||||
logger.error('[SessionLimit] Gagal membuat tabel login_sessions: ' + e.message);
|
||||
}
|
||||
|
||||
function now() { return Date.now(); }
|
||||
|
||||
/** Hapus baris yang sesi aslinya sudah tidak ada / kedaluwarsa. */
|
||||
function purgeDead() {
|
||||
try {
|
||||
db.prepare(`
|
||||
DELETE FROM login_sessions
|
||||
WHERE sid NOT IN (SELECT sid FROM sessions WHERE expires_at IS NULL OR expires_at > ?)
|
||||
`).run(now());
|
||||
} catch (e) { /* tabel sessions belum ada saat init awal */ }
|
||||
}
|
||||
|
||||
function rotateSession(req) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const oldSid = req.sessionID;
|
||||
req.session.regenerate((err) => {
|
||||
if (err) return reject(err);
|
||||
try {
|
||||
if (oldSid && oldSid !== req.sessionID) {
|
||||
db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(oldSid);
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(oldSid);
|
||||
}
|
||||
} catch (e) { /* abaikan */ }
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Catat sesi login dan terapkan batas maksimal.
|
||||
* @returns {{ allowed: boolean, active: number, limit: number }}
|
||||
*/
|
||||
function registerLoginSession(req, { role, accountId, limit, ip = '', userAgent = '' }) {
|
||||
const r = String(role || '').toLowerCase();
|
||||
const acc = String(accountId == null ? '' : accountId);
|
||||
const max = Number(limit) > 0 ? Number(limit) : (DEFAULT_LIMITS[r] || 2);
|
||||
const sid = req.sessionID;
|
||||
|
||||
if (!sid || !r || !acc) return { allowed: false, active: 0, limit: max };
|
||||
|
||||
purgeDead();
|
||||
|
||||
const existing = db.prepare('SELECT sid FROM login_sessions WHERE sid = ?').get(sid);
|
||||
if (existing) {
|
||||
db.prepare('UPDATE login_sessions SET last_seen = ? WHERE sid = ?').run(now(), sid);
|
||||
return { allowed: true, active: countActive(r, acc), limit: max };
|
||||
}
|
||||
|
||||
const rows = db.prepare(
|
||||
'SELECT sid FROM login_sessions WHERE role = ? AND account_id = ? ORDER BY created_at ASC'
|
||||
).all(r, acc);
|
||||
|
||||
const excess = rows.length - max + 1;
|
||||
if (excess > 0) {
|
||||
for (const row of rows.slice(0, excess)) {
|
||||
try {
|
||||
db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(row.sid);
|
||||
db.prepare('DELETE FROM sessions WHERE sid = ?').run(row.sid);
|
||||
logger.info(`[SessionLimit] Sesi lama ${r}:${acc} diputus (${row.sid.slice(0, 8)}…) karena melebihi batas ${max}.`);
|
||||
} catch (e) { /* abaikan */ }
|
||||
}
|
||||
}
|
||||
|
||||
db.prepare(`
|
||||
INSERT INTO login_sessions (sid, role, account_id, ip, user_agent, created_at, last_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(sid, r, acc, String(ip || '').slice(0, 64), String(userAgent || '').slice(0, 255), now(), now());
|
||||
|
||||
return { allowed: true, active: countActive(r, acc), limit: max };
|
||||
}
|
||||
|
||||
function countActive(role, accountId) {
|
||||
try {
|
||||
const row = db.prepare(
|
||||
'SELECT COUNT(*) as c FROM login_sessions WHERE role = ? AND account_id = ?'
|
||||
).get(String(role), String(accountId));
|
||||
return Number(row?.c || 0);
|
||||
} catch (e) { return 0; }
|
||||
}
|
||||
|
||||
function removeLoginSession(req) {
|
||||
try {
|
||||
if (req.sessionID) db.prepare('DELETE FROM login_sessions WHERE sid = ?').run(req.sessionID);
|
||||
} catch (e) { /* abaikan */ }
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_LIMITS,
|
||||
rotateSession,
|
||||
registerLoginSession,
|
||||
removeLoginSession,
|
||||
countActive,
|
||||
purgeDead
|
||||
};
|
||||
85
services/soundboxService.js
Normal file
@@ -0,0 +1,85 @@
|
||||
const db = require('../config/database');
|
||||
const { logger } = require('../config/logger');
|
||||
|
||||
// Pastikan tabel soundbox_events ada
|
||||
try {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS soundbox_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
speech_text TEXT NOT NULL,
|
||||
amount INTEGER DEFAULT 0,
|
||||
customer_name TEXT DEFAULT '',
|
||||
is_read INTEGER DEFAULT 0,
|
||||
created_at DATETIME DEFAULT (NOW_LOCAL())
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_soundbox_created ON soundbox_events(created_at);
|
||||
`);
|
||||
} catch (e) {
|
||||
logger.error('[Soundbox] Init table error: ' + e.message);
|
||||
}
|
||||
|
||||
function formatRupiahSpoken(n) {
|
||||
const num = Number(n || 0);
|
||||
return num.toLocaleString('id-ID') + ' rupiah';
|
||||
}
|
||||
|
||||
/**
|
||||
* Memasukkan notifikasi pembayaran masuk ke antrean Soundbox
|
||||
*/
|
||||
function queueSoundboxEvent({ type = 'payment', title = 'Pembayaran Masuk', speechText = '', amount = 0, customerName = '' }) {
|
||||
try {
|
||||
if (!speechText) {
|
||||
const spokenAmt = formatRupiahSpoken(amount);
|
||||
if (type === 'voucher') {
|
||||
speechText = `Pembayaran voucher hotspot ${customerName ? 'paket ' + customerName : ''} sebesar ${spokenAmt} berhasil diterima.`;
|
||||
} else if (type === 'invoice') {
|
||||
speechText = `Pembayaran tagihan ${customerName ? 'atas nama ' + customerName : ''} sebesar ${spokenAmt} berhasil lunas.`;
|
||||
} else if (type === 'donation') {
|
||||
speechText = `Donasi masuk sebesar ${spokenAmt} ${customerName ? 'dari ' + customerName : ''} berhasil diterima.`;
|
||||
} else if (type === 'topup') {
|
||||
speechText = `Top up saldo ${customerName ? 'atas nama ' + customerName : ''} sebesar ${spokenAmt} berhasil masuk.`;
|
||||
} else {
|
||||
speechText = `Pembayaran sebesar ${spokenAmt} berhasil diterima.`;
|
||||
}
|
||||
}
|
||||
|
||||
const res = db.prepare(`
|
||||
INSERT INTO soundbox_events (type, title, speech_text, amount, customer_name, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, (NOW_LOCAL()))
|
||||
`).run(type, title, speechText, Math.floor(amount || 0), customerName);
|
||||
|
||||
// Bersihkan entri lama jika > 200 agar database tetap ramping
|
||||
db.prepare('DELETE FROM soundbox_events WHERE id < (SELECT COALESCE(MAX(id), 0) - 200 FROM soundbox_events)').run();
|
||||
|
||||
logger.info(`[Soundbox] Event #${res.lastInsertRowid} (${type}): ${speechText}`);
|
||||
return res.lastInsertRowid;
|
||||
} catch (err) {
|
||||
logger.error('[Soundbox] Queue error: ' + err.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mengambil event terbaru untuk polling browser
|
||||
*/
|
||||
function getLatestEvents(afterId = 0, limit = 10) {
|
||||
try {
|
||||
const after = Number(afterId || 0);
|
||||
if (after > 0) {
|
||||
return db.prepare('SELECT * FROM soundbox_events WHERE id > ? ORDER BY id ASC LIMIT ?').all(after, limit);
|
||||
} else {
|
||||
return db.prepare('SELECT * FROM soundbox_events ORDER BY id DESC LIMIT ?').all(limit).reverse();
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('[Soundbox] Fetch error: ' + err.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
queueSoundboxEvent,
|
||||
getLatestEvents,
|
||||
formatRupiahSpoken
|
||||
};
|
||||
@@ -9,20 +9,56 @@ function getTechById(id) {
|
||||
return db.prepare('SELECT id, username, name, phone, area FROM technicians WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batas wilayah tugas teknisi.
|
||||
* area kosong / "semua" / "all" = tanpa batas (semua wilayah).
|
||||
*/
|
||||
function getTechAreaWhere(technician, tableAlias = 'c') {
|
||||
let tech = technician;
|
||||
if (typeof technician === 'number' || typeof technician === 'string') {
|
||||
tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(Number(technician));
|
||||
}
|
||||
const areas = String(tech?.area || '')
|
||||
.split(',')
|
||||
.map(a => a.trim().toLowerCase())
|
||||
.filter(a => a.length > 0 && a !== 'semua' && a !== 'all');
|
||||
|
||||
if (areas.length > 0) {
|
||||
const placeholders = areas.map(() => '?').join(', ');
|
||||
return {
|
||||
where: `LOWER(TRIM(${tableAlias}.area)) IN (${placeholders})`,
|
||||
params: areas,
|
||||
isRestricted: true,
|
||||
areas,
|
||||
};
|
||||
}
|
||||
return { where: '1=1', params: [], isRestricted: false, areas: [] };
|
||||
}
|
||||
|
||||
function canTechAccessCustomer(technician, customer) {
|
||||
if (!technician) return false;
|
||||
const filter = getTechAreaWhere(technician);
|
||||
if (!filter.isRestricted) return true;
|
||||
const custArea = String(customer?.area || '').trim().toLowerCase();
|
||||
return !!custArea && filter.areas.includes(custArea);
|
||||
}
|
||||
|
||||
function getTechStats(techId) {
|
||||
const total = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ?").get(techId).count;
|
||||
const inProgress = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'in_progress'").get(techId).count;
|
||||
const resolved = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE technician_id = ? AND status = 'resolved'").get(techId).count;
|
||||
|
||||
// Ambil open tickets yang belum ada teknisinya atau di-assign ke teknisi ini
|
||||
const open = db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
|
||||
const filter = techId ? getTechAreaWhere(techId, 'c') : null;
|
||||
const open = filter && filter.isRestricted
|
||||
? db.prepare(`SELECT COUNT(*) as count FROM tickets t JOIN customers c ON t.customer_id = c.id WHERE t.status = 'open' AND ${filter.where}`).get(...filter.params).count
|
||||
: db.prepare("SELECT COUNT(*) as count FROM tickets WHERE status = 'open'").get().count;
|
||||
|
||||
return { total, open, inProgress, resolved };
|
||||
}
|
||||
|
||||
function getAssignedTickets(techId) {
|
||||
return db.prepare(`
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||
FROM tickets t
|
||||
JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.technician_id = ? AND t.status != 'resolved'
|
||||
@@ -30,19 +66,21 @@ function getAssignedTickets(techId) {
|
||||
`).all(techId);
|
||||
}
|
||||
|
||||
function getOpenTickets() {
|
||||
function getOpenTickets(techId = null) {
|
||||
const filter = techId ? getTechAreaWhere(techId, 'c') : null;
|
||||
const areaWhere = filter && filter.isRestricted ? `AND ${filter.where}` : '';
|
||||
return db.prepare(`
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||
FROM tickets t
|
||||
JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.status = 'open'
|
||||
WHERE t.status = 'open' ${areaWhere}
|
||||
ORDER BY t.created_at DESC
|
||||
`).all();
|
||||
`).all(...(filter && filter.isRestricted ? filter.params : []));
|
||||
}
|
||||
|
||||
function getResolvedTickets(techId) {
|
||||
return db.prepare(`
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address
|
||||
SELECT t.*, c.name as customer_name, c.phone as customer_phone, c.address as customer_address, c.area as customer_area
|
||||
FROM tickets t
|
||||
JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.technician_id = ? AND t.status = 'resolved'
|
||||
@@ -52,11 +90,27 @@ function getResolvedTickets(techId) {
|
||||
|
||||
function takeTicket(ticketId, techId) {
|
||||
let validTechId = Number(techId);
|
||||
const exists = db.prepare('SELECT id FROM technicians WHERE id = ?').get(validTechId);
|
||||
if (!exists) {
|
||||
const fallback = db.prepare('SELECT id FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
|
||||
if (fallback) validTechId = fallback.id;
|
||||
let tech = db.prepare('SELECT id, area FROM technicians WHERE id = ?').get(validTechId);
|
||||
if (!tech) {
|
||||
const fallback = db.prepare('SELECT id, area FROM technicians WHERE is_active = 1 ORDER BY id ASC LIMIT 1').get();
|
||||
if (!fallback) throw new Error('Teknisi tidak ditemukan.');
|
||||
validTechId = fallback.id;
|
||||
tech = fallback;
|
||||
}
|
||||
|
||||
const ticket = db.prepare(`
|
||||
SELECT t.id, t.customer_id, t.status, c.area as customer_area
|
||||
FROM tickets t
|
||||
LEFT JOIN customers c ON t.customer_id = c.id
|
||||
WHERE t.id = ?
|
||||
`).get(ticketId);
|
||||
|
||||
if (!ticket) throw new Error('Tiket tidak ditemukan.');
|
||||
if (ticket.status !== 'open') throw new Error('Tiket sudah diambil oleh teknisi lain atau sudah selesai.');
|
||||
if (ticket.customer_id && !canTechAccessCustomer(tech, { area: ticket.customer_area })) {
|
||||
throw new Error(`Tiket ini berada di wilayah "${ticket.customer_area || 'Tanpa Area'}", di luar wilayah tugas Anda (${tech.area}).`);
|
||||
}
|
||||
|
||||
db.prepare("UPDATE tickets SET technician_id = ?, status = 'in_progress', updated_at = (NOW_LOCAL()) WHERE id = ?").run(validTechId, ticketId);
|
||||
return validTechId;
|
||||
}
|
||||
@@ -102,6 +156,8 @@ function createTechnician(data) {
|
||||
module.exports = {
|
||||
authenticate,
|
||||
getTechById,
|
||||
getTechAreaWhere,
|
||||
canTechAccessCustomer,
|
||||
getTechStats,
|
||||
getAssignedTickets,
|
||||
getOpenTickets,
|
||||
|
||||
904
services/telegramAiService.js
Normal file
@@ -0,0 +1,904 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const axios = require('axios');
|
||||
const { getSetting } = require('../config/settingsManager');
|
||||
const { logger } = require('../config/logger');
|
||||
const { adminToolDeclarations, executeAdminTool } = require('./adminAiTools');
|
||||
|
||||
// Cache untuk dokumen knowledge base
|
||||
let cachedKnowledge = null;
|
||||
let lastLoadTime = 0;
|
||||
const KNOWLEDGE_RELOAD_INTERVAL = 3600000; // Reload tiap 1 jam jika ada update doc
|
||||
|
||||
/**
|
||||
* Muat dan ringkas dokumentasi aplikasi agar efisien dalam prompt AI
|
||||
*/
|
||||
function loadKnowledgeBase() {
|
||||
const now = Date.now();
|
||||
if (cachedKnowledge && (now - lastLoadTime < KNOWLEDGE_RELOAD_INTERVAL)) {
|
||||
return cachedKnowledge;
|
||||
}
|
||||
|
||||
try {
|
||||
const docPath = path.join(__dirname, '../FITUR_APLIKASI_LENGKAP.md');
|
||||
if (fs.existsSync(docPath)) {
|
||||
const fullDoc = fs.readFileSync(docPath, 'utf8');
|
||||
|
||||
// Ambil seluruh dokumentasi fitur & panduan pemecahan masalah (mendukung context besar Gemini)
|
||||
cachedKnowledge = fullDoc.length > 350000 ? fullDoc.substring(0, 340000) + '\n\n...(dokumentasi dipotong untuk efisiensi context)...' : fullDoc;
|
||||
lastLoadTime = now;
|
||||
logger.info(`[TelegramAI] Knowledge base loaded (${cachedKnowledge.length} bytes).`);
|
||||
return cachedKnowledge;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[TelegramAI] Gagal memuat FITUR_APLIKASI_LENGKAP.md: ${err.message}`);
|
||||
}
|
||||
|
||||
// Fallback ringkasan jika file tidak terbaca
|
||||
cachedKnowledge = `
|
||||
Aplikasi: RTRWNET Management & Billing System (Botlokal Customer Portal)
|
||||
Tech Stack: Node.js (>=20.0.0), Express.js, SQLite (better-sqlite3), EJS, Baileys WA, RouterOS API, SNMP OLT, GenieACS TR-069.
|
||||
Fitur Utama:
|
||||
1. Billing & Invoicing: Auto generate invoice tiap tanggal billing, reminder WA otomatis (H-3, H-1, saat tempo), auto isolir jika jatuh tempo, denda telat, kode unik nominal pembayaran.
|
||||
2. MikroTik: Multi-router support, sinkronisasi secret PPPoE & user hotspot, auto create profile, auto isolir via address-list / pool, parser script mikhmon.
|
||||
3. GenieACS & TR-069: Auto provisioning ONU baru, cek redaman (optical power RX/TX), reboot ONU jarak jauh, ganti SSID dan sandi WiFi langsung via web/bot, status WAN & PPPoE uptime.
|
||||
4. OLT & SNMP: Dukungan OLT EPON & GPON (HIOSO, VSOL, ZTE, dll), monitor port PON, auto-discovery unconfigured ONU.
|
||||
5. Payment Gateway: Tripay, Midtrans, Xendit, Duitku, QRIS Dinamis & Statis, notifikasi mutasi otomatis via SMS/MacroDroid.
|
||||
6. WhatsApp & Telegram: Notifikasi tagihan, broadcast massal, chatbot interaktif pelanggan, bot Telegram untuk admin (cek Mikrotik, reboot ONU, cari pelanggan, ringkasan keuangan) & asisten grup.
|
||||
7. Portal Pelanggan: Cek tagihan tanpa login, riwayat invoice, ganti WiFi sendiri, beli voucher, tiket komplain gangguan.
|
||||
8. FreeRADIUS Server: Otentikasi PPPoE/Hotspot, radius accounting, dynamic pool isolir.
|
||||
`;
|
||||
lastLoadTime = now;
|
||||
return cachedKnowledge;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ambil status live seluruh pelanggan, MikroTik, dan GenieACS untuk Admin WhatsApp / Telegram
|
||||
*/
|
||||
async function getAdminGlobalContext(userQuestion) {
|
||||
try {
|
||||
const db = require('../config/database');
|
||||
const customerDevice = require('./customerDeviceService');
|
||||
const mikrotikService = require('./mikrotikService');
|
||||
|
||||
// 1. Ambil ringkasan seluruh pelanggan dari SQLite
|
||||
const query = `
|
||||
SELECT
|
||||
c.id, c.name, c.phone, c.status, c.pppoe_username, c.genieacs_tag, c.area,
|
||||
p.name as package_name, p.price as package_price,
|
||||
COUNT(CASE WHEN i.status != 'paid' THEN 1 END) as unpaid_count,
|
||||
COALESCE(SUM(CASE WHEN i.status != 'paid' THEN i.amount ELSE 0 END), 0) as unpaid_amount
|
||||
FROM customers c
|
||||
LEFT JOIN packages p ON c.package_id = p.id
|
||||
LEFT JOIN invoices i ON c.id = i.customer_id
|
||||
GROUP BY c.id
|
||||
ORDER BY c.id ASC
|
||||
`;
|
||||
const customers = db.prepare(query).all();
|
||||
|
||||
const total = customers.length;
|
||||
const active = customers.filter(c => (c.status || '').toLowerCase() === 'active').length;
|
||||
const isolated = customers.filter(c => ['isolated', 'suspended', 'isolir'].includes((c.status || '').toLowerCase())).length;
|
||||
const inactive = customers.filter(c => ['inactive', 'nonaktif', 'terminated'].includes((c.status || '').toLowerCase())).length;
|
||||
const unpaidCustomers = customers.filter(c => c.unpaid_count > 0);
|
||||
const totalUnpaidAmount = unpaidCustomers.reduce((acc, c) => acc + (c.unpaid_amount || 0), 0);
|
||||
|
||||
let contextText = `=== RINGKASAN GLOBAL SISTEM RTRW-NET (REALTIME ADMIN CONTEXT) ===\n`;
|
||||
contextText += `Total Pelanggan Terdaftar: ${total} pelanggan\n`;
|
||||
contextText += `- Aktif: ${active} pelanggan\n`;
|
||||
contextText += `- Terisolir / Suspended: ${isolated} pelanggan\n`;
|
||||
contextText += `- Non-Aktif / Terminated: ${inactive} pelanggan\n`;
|
||||
contextText += `Tunggakan Belum Lunas: ${unpaidCustomers.length} pelanggan (Total Tunggakan: Rp ${totalUnpaidAmount.toLocaleString('id-ID')})\n\n`;
|
||||
|
||||
// 2. Direktori ringkas seluruh pelanggan
|
||||
contextText += `=== DIREKTORI SELURUH PELANGGAN (ID | Nama | No HP | Status | PPPoE | Paket | Tunggakan) ===\n`;
|
||||
for (const c of customers) {
|
||||
const unpaidStr = c.unpaid_count > 0 ? `${c.unpaid_count} tagihan (Rp ${c.unpaid_amount.toLocaleString('id-ID')})` : 'Lunas';
|
||||
contextText += `[ID:${c.id}] ${c.name} | ${c.phone || '-'} | Status: ${c.status} | PPPoE: ${c.pppoe_username || '-'} | Pkt: ${c.package_name || '-'} | Tunggakan: ${unpaidStr}\n`;
|
||||
}
|
||||
contextText += `\n`;
|
||||
|
||||
// 3. Deteksi apakah admin menanyakan pelanggan tertentu
|
||||
const qLower = String(userQuestion || '').toLowerCase();
|
||||
const matchedCustomers = [];
|
||||
|
||||
for (const c of customers) {
|
||||
let isMatch = false;
|
||||
const name = String(c.name || '').toLowerCase().trim();
|
||||
const pppoe = String(c.pppoe_username || '').toLowerCase().trim();
|
||||
const phoneDigits = String(c.phone || '').replace(/\D/g, '');
|
||||
const tag = String(c.genieacs_tag || '').toLowerCase().trim();
|
||||
const idStr = String(c.id);
|
||||
|
||||
// Cek ID
|
||||
if (new RegExp(`\\b(id|#)\\s*${idStr}\\b`, 'i').test(qLower)) {
|
||||
isMatch = true;
|
||||
}
|
||||
// Cek No HP
|
||||
else if (phoneDigits.length >= 8 && qLower.includes(phoneDigits.slice(-8))) {
|
||||
isMatch = true;
|
||||
}
|
||||
// Cek PPPoE username
|
||||
else if (pppoe.length >= 3 && qLower.includes(pppoe)) {
|
||||
isMatch = true;
|
||||
}
|
||||
// Cek GenieACS Tag
|
||||
else if (tag.length >= 3 && qLower.includes(tag)) {
|
||||
isMatch = true;
|
||||
}
|
||||
// Cek Nama Pelanggan
|
||||
else if (name.length >= 3) {
|
||||
if (qLower.includes(name)) {
|
||||
isMatch = true;
|
||||
} else {
|
||||
const words = name.split(/\s+/).filter(w => w.length >= 4);
|
||||
for (const w of words) {
|
||||
if (['bisa', 'tolong', 'admin', 'saya', 'yang', 'dari', 'untuk', 'paket', 'status', 'kabel', 'modem', 'wifi', 'cek'].includes(w)) continue;
|
||||
if (qLower.includes(w)) {
|
||||
isMatch = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (isMatch) {
|
||||
matchedCustomers.push(c);
|
||||
if (matchedCustomers.length >= 3) break;
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Jika ada pelanggan yang cocok, lakukan Deep Dive (Invoices + GenieACS + MikroTik)
|
||||
if (matchedCustomers.length > 0) {
|
||||
contextText += `=== DETAIL MENDALAM PELANGGAN YANG DITANYAKAN ===\n`;
|
||||
|
||||
let activeSessionsMap = new Map();
|
||||
try {
|
||||
activeSessionsMap = await Promise.race([
|
||||
mikrotikService.getActivePppoeSessionsMap(),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('MikroTik timeout')), 4000))
|
||||
]).catch(() => new Map());
|
||||
} catch (_) {}
|
||||
|
||||
for (const mc of matchedCustomers) {
|
||||
const invRows = db.prepare('SELECT id, period_month, period_year, amount, status, paid_at, created_at FROM invoices WHERE customer_id = ? ORDER BY id DESC LIMIT 5').all(mc.id);
|
||||
const unpaidInvs = invRows.filter(i => i.status !== 'paid');
|
||||
const paidInvs = invRows.filter(i => i.status === 'paid');
|
||||
|
||||
let onuData = null;
|
||||
const lookupTag = mc.genieacs_tag || mc.pppoe_username || mc.phone;
|
||||
if (lookupTag) {
|
||||
try {
|
||||
onuData = await Promise.race([
|
||||
customerDevice.getCustomerDeviceData(lookupTag),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('GenieACS timeout')), 4000))
|
||||
]).catch(() => null);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
const pppoeUser = (mc.pppoe_username || '').toLowerCase();
|
||||
const pppoeSession = pppoeUser ? activeSessionsMap.get(pppoeUser) : null;
|
||||
|
||||
contextText += `\n>> PELANGGAN: ${mc.name} (ID: ${mc.id})\n`;
|
||||
contextText += ` - No HP: ${mc.phone || '-'}\n`;
|
||||
contextText += ` - Status Billing: ${mc.status}\n`;
|
||||
contextText += ` - Area: ${mc.area || '-'}\n`;
|
||||
contextText += ` - Paket: ${mc.package_name || '-'} (Rp ${Number(mc.package_price || 0).toLocaleString('id-ID')}/bulan)\n`;
|
||||
contextText += ` - Akun PPPoE: ${mc.pppoe_username || '-'}\n`;
|
||||
contextText += ` - Status PPPoE MikroTik: ${pppoeSession ? `ONLINE (IP: ${pppoeSession.ip}, Uptime: ${pppoeSession.uptime}, Router: ${pppoeSession.routerName || '-'})` : 'OFFLINE / Belum dial'}\n`;
|
||||
|
||||
if (onuData) {
|
||||
contextText += ` - Status Modem ONU: ${onuData.status || 'Unknown'}\n`;
|
||||
contextText += ` - Redaman Optical (RX Power): ${onuData.rxPower ? onuData.rxPower + ' dBm' : '-'}\n`;
|
||||
contextText += ` - Nama WiFi (SSID): ${onuData.ssid || '-'}\n`;
|
||||
contextText += ` - ONU Uptime: ${onuData.uptime || '-'}\n`;
|
||||
contextText += ` - Pengguna WiFi Terhubung: ${onuData.totalAssociations || 0} user\n`;
|
||||
} else {
|
||||
contextText += ` - Status Modem ONU: (Data modem belum terhubung/tag GenieACS belum diisi)\n`;
|
||||
}
|
||||
|
||||
contextText += ` - Rincian Tunggakan Tagihan: ${unpaidInvs.length > 0 ? unpaidInvs.map(i => `#${i.id} (Periode ${i.period_month}/${i.period_year}: Rp ${Number(i.amount).toLocaleString('id-ID')})`).join(', ') : 'TIDAK ADA (Semua tagihan LUNAS)'}\n`;
|
||||
if (paidInvs.length > 0) {
|
||||
contextText += ` - Tagihan Terakhir Lunas: #${paidInvs[0].id} (Periode ${paidInvs[0].period_month}/${paidInvs[0].period_year} sebesar Rp ${Number(paidInvs[0].amount).toLocaleString('id-ID')} pada ${paidInvs[0].paid_at || '-'})\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return contextText;
|
||||
} catch (err) {
|
||||
logger.error(`[TelegramAI] Gagal mengambil admin global context: ${err.message}`);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Buat System Instruction Terpadu untuk AI
|
||||
*/
|
||||
function buildSystemInstruction(isAdmin, customerContext, adminContext, senderName) {
|
||||
const knowledge = loadKnowledgeBase();
|
||||
|
||||
if (isAdmin) {
|
||||
return `Anda adalah asisten cerdas, serba tahu, dan handal yang mendampingi Pemilik / Administrator "RTRWNET Management & Billing System" (Botlokal).
|
||||
|
||||
PEDOMAN UTAMA & ATURAN JAWABAN:
|
||||
1. TANPA BASA-BASI / TANPA DISCLAIMER KAKU:
|
||||
- DILARANG KERAS menggunakan frasa robotik seperti: "Sebagai Asisten AI...", "Sebagai Asisten AI Eksekutif...", "Sebagai model bahasa...", "Mohon maaf sepertinya Anda bercanda/terpotong...", atau "Saya hanya bisa membantu seputar billing...".
|
||||
- Langsung berikan jawaban yang cerdas, tepat sasaran, to-the-point, dan santun layaknya rekan kerja manusia yang pintar dan berwawasan luas.
|
||||
2. JAWAB SEMUA PERTANYAAN (TERMASUK DI LUAR TOPIK BILLING):
|
||||
- Jika admin menanyakan hal apa pun di luar topik billing (seperti pengetahuan umum, tokoh dunia/politik, sains, sejarah, teknologi, edukasi, coding, resep masakan, cuaca, tips harian, obrolan santai, dll.), JAWABLAH DENGAN LENGKAP, AKURAT, DAN CERDAS menggunakan seluruh kemampuan wawasan umum Anda sendiri!
|
||||
- JANGAN PERNAH menolak pertanyaan atau menganggap pertanyaan bercanda/salah hanya karena bukan tentang internet/billing.
|
||||
3. DATA OPERASIONAL LIVE & STATUS PELANGGAN:
|
||||
- Jika admin menanyakan status pelanggan tertentu (misal: "apakah diva nunggak?", "cek redaman diva", "siapa yang terisolir?", "kenapa pelanggan X putus?"), gunakan DATA OPERASIONAL LIVE di bawah ini untuk menjawab secara presisi dan profesional.
|
||||
- Jika admin menanyakan ringkasan (misal: "berapa yang isolir?", "siapa saja yang belum bayar?", "total uang tunggakan?"), rangkum dengan angka pasti dan sebutkan nama-nama pelanggan secara rapi.
|
||||
4. SISTEM, TEKNIS, & TROUBLESHOOTING JARINGAN:
|
||||
- Jika admin menanyakan kendala teknis (MikroTik API timeout, redaman jelek/LOS, isolir bypass, webhook payment gateway, dll.), berikan diagnosis penyebab dan solusi konkret mengacu pada DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH di bawah.
|
||||
5. EKSEKUSI PERINTAH ADMINISTRATIF VIA TOOLS:
|
||||
- Anda DILENGKAPI FUNCTION TOOLS untuk mengeksekusi aksi administratif. Jika pesan admin mengandung instruksi / perintah tindakan (tambah pelanggan, hapus pelanggan, reboot/restart modem ONU, ganti password/SSID WiFi, lunasi tagihan, isolir, buka isolir, kick user aktif, buat voucher hotspot, generate invoice bulanan, cek saldo digiflazz, atau top up agent), PANGGIL FUNCTION TOOL YANG SESUAI dengan parameter yang diekstrak dari percakapan.
|
||||
- Untuk hapus pelanggan: SELALU panggil tool 'delete_customer_request' agar sistem meminta konfirmasi 2 langkah yang aman.
|
||||
- Untuk tambah pelanggan: Panggil 'add_customer' dengan mengekstrak data dari pesan admin.
|
||||
- Jika admin HANYA bertanya informasi tanpa memerintahkan perubahan data/eksekusi aksi, JANGAN panggil tool pengubah data, melainkan berikan jawaban teks langsung.
|
||||
6. FORMAT LISAN NATURAL (VOICE & CHAT READY - TANPA SIMBOL & TANPA EMOJI):
|
||||
- Jawaban Anda akan dibacakan langsung oleh mesin Text-to-Speech (TTS) dan ditampilkan pada aplikasi mobile.
|
||||
- DILARANG KERAS MENGGUNAKAN SIMBOL ATAU EMOJI APAPUN (seperti lingkaran warna 🟢, 🔴, 🟡, ⚪, peringatan ⚠️, sinyal 📡, centang ✅, silang ❌, robot 🤖, dll.) karena akan dibaca salah oleh mesin suara menjadi "lingkaran hijau besar", "lingkaran merah", dll.
|
||||
- DILARANG MENGGUNAKAN KARAKTER MARKDOWN KAKU seperti bullet points (•, -, *), tanda pagar (#), tanda bintang ganda, backticks, atau tabel.
|
||||
- Jangan menyajikan data teknis mentah baris-demi-baris yang kaku. SEDERHANAKAN DATA dan rangkum menjadi kalimat percakapan yang mengalir enak didengar.
|
||||
- Tambahkan logat bicara yang santun, luwes, hangat, dan ramah seperti asisten pribadi profesional, jangan kaku atau robotik.
|
||||
- DILARANG KERAS menggunakan header formal di awal pesan atau template penutup klise di akhir pesan (seperti "Semoga membantu", "Ada yang bisa dibantu lagi").
|
||||
- Langsung berikan penjelasan atau ringkasan data ke inti jawaban secara bersahabat.
|
||||
|
||||
7. PENYEDERHANAAN DAFTAR PANJANG (DATA DISAJIKAN RINGKAS & LISAN):
|
||||
- Jika data yang ditanyakan berjumlah banyak (seperti daftar penunggak, pelanggan aktif, dll.), DILARANG KERAS menyebutkan puluhan nama satu per satu karena akan terlalu panjang dan melelahkan saat didengarkan.
|
||||
- CUKUP SEBUTKAN total jumlahnya, total nilai nominalnya, dan sebutkan 3 sampai 5 nama teratas sebagai perwakilan.
|
||||
- Akhiri dengan tawaran santun, misalnya: "Jika Anda ingin mengecek rincian pelanggan tertentu, Anda cukup sebutkan namanya."
|
||||
|
||||
DATA OPERASIONAL LIVE (ADMIN ACCESS):
|
||||
${adminContext || '(Tidak ada data kontekstual khusus)'}
|
||||
|
||||
DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH:
|
||||
${knowledge}
|
||||
`;
|
||||
}
|
||||
|
||||
let liveCustomerDataPrompt = '';
|
||||
if (customerContext) {
|
||||
liveCustomerDataPrompt = `
|
||||
----------------------------------------
|
||||
DATA STATUS LIVE PELANGGAN (Dari Database Server Billing):
|
||||
- Nama Pelanggan: ${customerContext.customerName || senderName}
|
||||
- Paket Layanan: ${customerContext.package || '-'}
|
||||
- Akun PPPoE: ${customerContext.pppoeUsername || '-'}
|
||||
- Status Langganan: ${customerContext.customerStatus || 'Active'}
|
||||
- Status Tagihan Saat Ini: ${customerContext.hasUnpaid ? `MEMILIKI ${customerContext.unpaidCount} TAGIHAN BELUM LUNAS (Total: Rp ${customerContext.totalUnpaidAmount.toLocaleString('id-ID')})` : (customerContext.allPaid ? 'SEMUA TAGIHAN SUDAH LUNAS' : 'Belum ada data tagihan')}
|
||||
${customerContext.unpaidInvoices ? `Rincian Tagihan Belum Lunas:\n${customerContext.unpaidInvoices}` : ''}
|
||||
|
||||
PETUNJUK KHUSUS JIKA MENANYAKAN TAGIHAN / TUNGGAKAN / PEMBAYARAN:
|
||||
Jika pelanggan bertanya mengenai sisa tagihan, status pembayaran, jumlah uang yang harus dibayar, atau tunggakan miliknya, gunakan DATA STATUS LIVE di atas! Sebutkan nama pelanggan, total tagihan yang belum lunas beserta rincian periode bulannya secara sopan, jelas, dan ramah. Jika semua tagihan sudah lunas, beritahukan bahwa tagihan sudah lunas dan ucapkan terima kasih.
|
||||
----------------------------------------
|
||||
`;
|
||||
}
|
||||
|
||||
return `Anda adalah asisten cerdas, ramah, dan solutif untuk komunitas dan layanan pelanggan "RTRWNET Management & Billing System" (Botlokal).
|
||||
|
||||
PEDOMAN UTAMA & ATURAN JAWABAN:
|
||||
1. TANPA BASA-BASI KAKU:
|
||||
- DILARANG menggunakan kata-kata klise seperti "Sebagai Asisten AI...", "Sebagai model bahasa...", atau menolak pertanyaan. Langsung jawab ke inti pertanyaan dengan ramah, santun, dan bersahabat.
|
||||
2. JAWAB PERTANYAAN UMUM & DI LUAR APLIKASI:
|
||||
- Jika anggota grup atau pelanggan bertanya tentang topik umum di luar aplikasi (pengetahuan umum, sains, teknologi, komputer, tips harian, edukasi, obrolan santai), JAWABLAH DENGAN CERDAS, INFORMATIF, DAN BERMANFAAT menggunakan seluruh wawasan Anda sendiri.
|
||||
3. PERTANYAAN TERKAIT FITUR & TROUBLESHOOTING APLIKASI:
|
||||
- Jika berkaitan dengan layanan internet, cara bayar tagihan, cara ganti sandi wifi, cek tagihan, atau troubleshooting jaringan rumah, jawablah dengan panduan praktis yang mengacu pada DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH di bawah.
|
||||
4. PERTANYAAN DATA TAGIHAN PRIBADI PELANGGAN:
|
||||
- Jika tersedia DATA STATUS LIVE PELANGGAN di bawah, gunakan informasi riil tersebut untuk menjawab status tunggakan atau pembayaran pelanggan secara sopan, jelas, dan ramah.
|
||||
5. FORMAT LISAN NATURAL & BEBAS SIMBOL/EMOJI:
|
||||
- Jawablah seperti percakapan personal yang wajar, luwes, ramah, dan santun layaknya manusia (tanpa robotik).
|
||||
- DILARANG menggunakan simbol grafis atau emoji apapun (lingkaran 🟢, 🔴, 🟡, ⚠️, 💰, 📋, 🙏, dll.) dan hindari karakter formatting kaku (bullet point, bintang, pagar).
|
||||
- DILARANG menggunakan header kaku atau kalimat footer penutup klise.
|
||||
- Sampaikan penjelasan dengan bahasa yang santun, mudah dipahami, dan mengalir natural saat dibacakan suara.
|
||||
|
||||
${liveCustomerDataPrompt}
|
||||
|
||||
DOKUMENTASI SISTEM & PANDUAN PEMECAHAN MASALAH:
|
||||
${knowledge}
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konversi skema function calling Gemini ke format standar OpenAI tools
|
||||
*/
|
||||
function convertGeminiToolsToOpenAI(declarations) {
|
||||
if (!Array.isArray(declarations) || declarations.length === 0) return [];
|
||||
|
||||
function lowercaseTypes(schema) {
|
||||
if (!schema || typeof schema !== 'object') return schema;
|
||||
const copy = Array.isArray(schema) ? [] : {};
|
||||
for (const [key, val] of Object.entries(schema)) {
|
||||
if (key === 'type' && typeof val === 'string') {
|
||||
copy[key] = val.toLowerCase();
|
||||
} else if (typeof val === 'object' && val !== null) {
|
||||
copy[key] = lowercaseTypes(val);
|
||||
} else {
|
||||
copy[key] = val;
|
||||
}
|
||||
}
|
||||
return copy;
|
||||
}
|
||||
|
||||
return declarations.map(decl => ({
|
||||
type: 'function',
|
||||
function: {
|
||||
name: decl.name,
|
||||
description: decl.description,
|
||||
parameters: decl.parameters ? lowercaseTypes(decl.parameters) : { type: 'object', properties: {} }
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Panggil Google Gemini API
|
||||
*/
|
||||
async function callGeminiApi(apiKey, userQuestion, senderName = 'Member', customerContext = null, isAdmin = false, adminContext = null) {
|
||||
const models = ['gemini-2.5-flash', 'gemini-2.0-flash', 'gemini-1.5-flash'];
|
||||
const preferredModel = getSetting('gemini_model', 'gemini-2.5-flash');
|
||||
const modelList = [preferredModel, ...models.filter(m => m !== preferredModel)];
|
||||
const systemInstruction = buildSystemInstruction(isAdmin, customerContext, adminContext, senderName);
|
||||
|
||||
let lastError = null;
|
||||
|
||||
for (const model of modelList) {
|
||||
try {
|
||||
const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${apiKey}`;
|
||||
const payload = {
|
||||
contents: [
|
||||
{
|
||||
role: 'user',
|
||||
parts: [
|
||||
{
|
||||
text: `${systemInstruction}\n\nPesan / Pertanyaan dari ${isAdmin ? `Admin (${senderName})` : senderName}:\n${userQuestion}`
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
generationConfig: {
|
||||
temperature: 0.2,
|
||||
maxOutputTokens: 1024
|
||||
}
|
||||
};
|
||||
|
||||
if (isAdmin && Array.isArray(adminToolDeclarations) && adminToolDeclarations.length > 0) {
|
||||
payload.tools = [
|
||||
{
|
||||
functionDeclarations: adminToolDeclarations
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
const response = await axios.post(url, payload, {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 25000
|
||||
});
|
||||
|
||||
const candidate = response.data?.candidates?.[0];
|
||||
const parts = candidate?.content?.parts || [];
|
||||
|
||||
// Periksa apakah model memutuskan memanggil function tool
|
||||
const funcPart = parts.find(p => p.functionCall);
|
||||
if (funcPart && funcPart.functionCall) {
|
||||
return {
|
||||
type: 'function_call',
|
||||
functionCall: {
|
||||
name: funcPart.functionCall.name,
|
||||
args: funcPart.functionCall.args || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Jawaban teks biasa
|
||||
const textPart = parts.find(p => p.text && p.text.trim());
|
||||
if (textPart && textPart.text) {
|
||||
return {
|
||||
type: 'text',
|
||||
text: textPart.text.trim()
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const status = err.response?.status;
|
||||
if (status === 404) {
|
||||
logger.warn(`[TelegramAI] Model ${model} tidak tersedia (404), mencoba model cadangan...`);
|
||||
continue;
|
||||
}
|
||||
if (status === 429) {
|
||||
logger.warn(`[TelegramAI] Model ${model} kena limit (429), mencoba model cadangan...`);
|
||||
continue;
|
||||
}
|
||||
if (status === 400) {
|
||||
throw new Error('API Key Gemini tidak valid atau belum diaktifkan.');
|
||||
}
|
||||
logger.warn(`[TelegramAI] Error calling ${model}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError || new Error('Gagal mendapatkan respon dari AI.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Panggil OpenAI-Compatible API (OpenAI, DeepSeek, Groq, OpenRouter, Custom / Local Ollama)
|
||||
*/
|
||||
async function callOpenAiCompatibleApi({
|
||||
apiKey,
|
||||
baseUrl,
|
||||
model,
|
||||
userQuestion,
|
||||
senderName = 'Member',
|
||||
customerContext = null,
|
||||
isAdmin = false,
|
||||
adminContext = null
|
||||
}) {
|
||||
const systemInstruction = buildSystemInstruction(isAdmin, customerContext, adminContext, senderName);
|
||||
const cleanBaseUrl = String(baseUrl || 'https://api.openai.com/v1').trim().replace(/\/+$/, '');
|
||||
const url = `${cleanBaseUrl}/chat/completions`;
|
||||
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: systemInstruction
|
||||
},
|
||||
{
|
||||
role: 'user',
|
||||
content: `Pesan / Pertanyaan dari ${isAdmin ? `Admin (${senderName})` : senderName}:\n${userQuestion}`
|
||||
}
|
||||
];
|
||||
|
||||
const payload = {
|
||||
model: model,
|
||||
messages: messages,
|
||||
temperature: 0.2
|
||||
};
|
||||
|
||||
if (isAdmin && Array.isArray(adminToolDeclarations) && adminToolDeclarations.length > 0) {
|
||||
payload.tools = convertGeminiToolsToOpenAI(adminToolDeclarations);
|
||||
payload.tool_choice = 'auto';
|
||||
}
|
||||
|
||||
const headers = {
|
||||
'Content-Type': 'application/json'
|
||||
};
|
||||
|
||||
if (apiKey) {
|
||||
headers['Authorization'] = `Bearer ${apiKey.trim()}`;
|
||||
}
|
||||
|
||||
if (cleanBaseUrl.includes('openrouter.ai')) {
|
||||
headers['HTTP-Referer'] = 'https://alijayanet.net';
|
||||
headers['X-Title'] = 'Alijaya Billing System';
|
||||
}
|
||||
|
||||
const response = await axios.post(url, payload, {
|
||||
headers,
|
||||
timeout: 35000
|
||||
});
|
||||
|
||||
const choice = response.data?.choices?.[0];
|
||||
const message = choice?.message;
|
||||
|
||||
// Periksa apakah model memutuskan memanggil function tool
|
||||
if (message?.tool_calls && Array.isArray(message.tool_calls) && message.tool_calls.length > 0) {
|
||||
const firstCall = message.tool_calls[0];
|
||||
let parsedArgs = {};
|
||||
if (typeof firstCall.function?.arguments === 'string') {
|
||||
try {
|
||||
parsedArgs = JSON.parse(firstCall.function.arguments);
|
||||
} catch (e) {
|
||||
parsedArgs = {};
|
||||
}
|
||||
} else if (typeof firstCall.function?.arguments === 'object' && firstCall.function?.arguments !== null) {
|
||||
parsedArgs = firstCall.function.arguments;
|
||||
}
|
||||
|
||||
return {
|
||||
type: 'function_call',
|
||||
functionCall: {
|
||||
name: firstCall.function?.name,
|
||||
args: parsedArgs || {}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Jawaban teks biasa
|
||||
if (message?.content && typeof message.content === 'string' && message.content.trim()) {
|
||||
return {
|
||||
type: 'text',
|
||||
text: message.content.trim()
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error('Respon AI kosong dari provider.');
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback Jawaban Cerdas berbasis Kata Kunci jika belum ada Gemini API Key atau offline
|
||||
*/
|
||||
function getOfflineAnswer(question, customerContext = null, isAdmin = false, adminContext = null) {
|
||||
const q = String(question || '').toLowerCase();
|
||||
|
||||
// Khusus Admin jika offline: Jawab ringkasan data riil dari database
|
||||
if (isAdmin) {
|
||||
try {
|
||||
const db = require('../config/database');
|
||||
|
||||
// 1. Cek pertanyaan tunggakan / tagihan belum lunas
|
||||
if (q.includes('tunggak') || q.includes('belum bayar') || q.includes('tagihan') || q.includes('piutang') || q.includes('nunggak')) {
|
||||
const unpaidList = db.prepare(`
|
||||
SELECT c.id, c.name, c.phone, c.status, COUNT(i.id) as count, SUM(i.amount) as total
|
||||
FROM customers c
|
||||
JOIN invoices i ON c.id = i.customer_id
|
||||
WHERE i.status != 'paid'
|
||||
GROUP BY c.id
|
||||
ORDER BY total DESC
|
||||
`).all();
|
||||
|
||||
if (unpaidList.length === 0) {
|
||||
return 'Alhamdulillah, saat ini seluruh pelanggan sudah melunasi tagihannya dan tidak ada tunggakan.';
|
||||
}
|
||||
|
||||
const totalNominal = unpaidList.reduce((sum, item) => sum + Number(item.total || 0), 0);
|
||||
const topNames = unpaidList.slice(0, 3).map(c => c.name).join(', ');
|
||||
return `Saat ini ada ${unpaidList.length} pelanggan yang memiliki tagihan belum lunas dengan total nilai sekitar ${totalNominal.toLocaleString('id-ID')} rupiah. Beberapa di antaranya yaitu ${topNames}. Apakah Anda ingin rincian tagihan tertentu?`;
|
||||
}
|
||||
|
||||
// 2. Cek pertanyaan isolir / terisolir / putus
|
||||
if (q.includes('isolir') || q.includes('suspend') || q.includes('putus')) {
|
||||
const isolatedList = db.prepare(`
|
||||
SELECT id, name, phone, pppoe_username, area
|
||||
FROM customers
|
||||
WHERE LOWER(status) IN ('isolated', 'suspended', 'isolir')
|
||||
ORDER BY id ASC
|
||||
`).all();
|
||||
|
||||
if (isolatedList.length === 0) {
|
||||
return 'Saat ini tidak ada pelanggan yang sedang terisolir. Seluruh pelanggan terpantau dalam status aktif dan normal.';
|
||||
}
|
||||
|
||||
const isoNames = isolatedList.map(c => c.name).join(', ');
|
||||
return `Saat ini ada ${isolatedList.length} pelanggan yang sedang terisolir, yaitu atas nama ${isoNames}. Layanan mereka akan langsung aktif begitu tagihan dilunasi.`;
|
||||
}
|
||||
|
||||
// 3. Cek total / ringkasan / rekap
|
||||
if (q.includes('total') || q.includes('berapa') || q.includes('rekap') || q.includes('ringkasan') || q.includes('pelanggan')) {
|
||||
const stats = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
COUNT(CASE WHEN LOWER(status) = 'active' THEN 1 END) as active,
|
||||
COUNT(CASE WHEN LOWER(status) IN ('isolated', 'suspended', 'isolir') THEN 1 END) as isolated,
|
||||
COUNT(CASE WHEN LOWER(status) IN ('inactive', 'terminated', 'nonaktif') THEN 1 END) as inactive
|
||||
FROM customers
|
||||
`).get();
|
||||
|
||||
return `Saat ini tercatat ada total ${stats.total} pelanggan di dalam sistem. Rinciannya terdiri dari ${stats.active} pelanggan aktif, ${stats.isolated} pelanggan terisolir, dan ${stats.inactive} pelanggan nonaktif. Secara umum seluruh jaringan terpantau lancar.`;
|
||||
}
|
||||
|
||||
// 4. Cek modem / redaman / ont / onu / sinyal
|
||||
if (q.includes('modem') || q.includes('onu') || q.includes('ont') || q.includes('redaman') || q.includes('sinyal') || q.includes('kabel optik')) {
|
||||
const allCustomers = db.prepare('SELECT id, name, pppoe_username, phone, status, genieacs_tag FROM customers').all();
|
||||
let matchedCust = null;
|
||||
for (const c of allCustomers) {
|
||||
const lowerName = (c.name || '').toLowerCase();
|
||||
const firstName = lowerName.split(/\s+/)[0];
|
||||
if (firstName && firstName.length > 2 && q.includes(firstName)) {
|
||||
matchedCust = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (matchedCust) {
|
||||
return `Kondisi modem milik ${matchedCust.name} saat ini terpantau normal dan aktif di sistem. Status layanannya ${matchedCust.status === 'active' ? 'aktif lancar' : matchedCust.status}, dan koneksi internetnya berjalan baik.`;
|
||||
}
|
||||
|
||||
return 'Secara keseluruhan kondisi modem pelanggan dan router terpantau aktif normal. Kualitas redaman optik ideal berada di kisaran minus lima belas sampai minus dua puluh dua desibel. Jika Anda ingin mengecek modem pelanggan tertentu, cukup sebutkan nama pelanggannya.';
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`[TelegramAI] Offline admin answer error: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Jika pelanggan menanyakan tagihan/tunggakan dan ada data live pelanggan
|
||||
if (customerContext && (q.includes('tagihan') || q.includes('tunggak') || q.includes('bayar') || q.includes('lunas') || q.includes('tempo') || q.includes('invoice'))) {
|
||||
if (customerContext.hasUnpaid) {
|
||||
return `Halo Kak ${customerContext.customerName}, saat ini ada ${customerContext.unpaidCount} tagihan belum lunas dengan total sebesar ${customerContext.totalUnpaidAmount.toLocaleString('id-ID')} rupiah. Pembayaran dapat dilakukan via transfer bank atau QRIS di portal pelanggan.`;
|
||||
} else if (customerContext.allPaid) {
|
||||
return `Halo Kak ${customerContext.customerName}, terima kasih banyak. Seluruh tagihan internet Anda sudah tercatat lunas dan koneksi internet Anda aktif lancar.`;
|
||||
}
|
||||
}
|
||||
|
||||
// --- DIAGNOSTIK & PEMECAHAN MASALAH (TROUBLESHOOTING) DENGAN BAHASA LISAN NATURAL ---
|
||||
|
||||
// 1. Masalah Sudah Lunas tapi Masih Isolir
|
||||
if (q.includes('masih isolir') || (q.includes('sudah bayar') && q.includes('isolir')) || (q.includes('lunas') && q.includes('isolir')) || q.includes('sudah bayar tapi')) {
|
||||
return 'Jika pelanggan sudah lunas tetapi statusnya masih terisolir, biasanya alamat IP pelanggan masih tersimpan di daftar isolir MikroTik. Anda dapat membuka menu data pelanggan di web lalu klik tombol buka isolir, atau katakan buka isolir diikuti nama pelanggan.';
|
||||
}
|
||||
|
||||
// 2. Masalah Terisolir tapi Masih Bisa Internetan
|
||||
if (q.includes('masih bisa') && (q.includes('isolir') || q.includes('suspend') || q.includes('putus'))) {
|
||||
return 'Jika pelanggan berstatus terisolir namun masih dapat mengakses internet, kemungkinan sesi aktif lamanya belum diputus oleh router. Anda bisa melakukan kick sesi aktif atau memastikan aturan firewall isolir berada di urutan teratas pada router MikroTik.';
|
||||
}
|
||||
|
||||
// 3. Masalah Koneksi MikroTik API (Timeout / Gagal Konek)
|
||||
if (q.includes('tidak bisa konek') || q.includes('gagal konek') || q.includes('api timeout') || q.includes('port 8728') || q.includes('port 8729') || q.includes('service api')) {
|
||||
return 'Jika sistem gagal terhubung ke MikroTik API, pastikan layanan API pada port delapan ribu tujuh ratus dua puluh delapan sudah diaktifkan di menu IP Service router, dan pastikan user serta password di menu Routers sudah sesuai.';
|
||||
}
|
||||
|
||||
// 4. Masalah Redaman Optik / LOS Lampu Merah
|
||||
if ((q.includes('redaman') && (q.includes('jelek') || q.includes('tinggi') || q.includes('drop') || q.includes('dbm') || q.includes('solusi') || q.includes('masalah') || q.includes('kenapa') || q.includes('buruk'))) || q.includes('los') || q.includes('lampu merah') || q.includes('kabel putus') || q.includes('rx power')) {
|
||||
return 'Nilai redaman optik yang ideal berada di kisaran minus lima belas sampai minus dua puluh dua desibel. Jika redaman lebih tinggi dari minus dua puluh delapan desibel atau muncul lampu merah sinyal putus, periksa kemungkinan kabel optik tertekuk atau konektor kotor yang perlu dibersihkan.';
|
||||
}
|
||||
|
||||
// 5. Masalah Webhook / Payment Gateway / QRIS
|
||||
if (q.includes('webhook') || (q.includes('qris') && (q.includes('gagal') || q.includes('belum lunas') || q.includes('unpaid') || q.includes('tidak otomatis')))) {
|
||||
return 'Jika pembayaran QRIS belum otomatis lunas, pastikan URL callback webhook di dashboard payment gateway sudah terpasang dengan benar dan server menggunakan protokol HTTPS yang valid. Anda juga bisa melunasi tagihan secara manual melalui perintah suara asisten.';
|
||||
}
|
||||
|
||||
// 6. Masalah WhatsApp "Menunggu Pesan Ini" / Logout
|
||||
if (q.includes('menunggu pesan ini') || q.includes('waiting for this message') || q.includes('wa logout') || q.includes('wa putus')) {
|
||||
return 'Jika sesi WhatsApp sering terputus, pastikan ponsel yang terhubung memiliki koneksi internet yang stabil dan mode hemat baterai telah dimatikan. Anda juga bisa melakukan reset sesi dan scan ulang kode QR di menu WhatsApp Gateway.';
|
||||
}
|
||||
|
||||
// 7. Masalah Telegram Bot
|
||||
if (q.includes('telegram') && (q.includes('akses ditolak') || q.includes('conflict') || q.includes('409') || q.includes('tidak respon'))) {
|
||||
return 'Jika akses bot Telegram ditolak, pastikan ID Telegram Anda sudah dimasukkan ke pengaturan Telegram Admin ID di web admin.';
|
||||
}
|
||||
|
||||
// 8. Masalah Database SQLite Locked
|
||||
if (q.includes('database is locked') || q.includes('sqlite_busy') || q.includes('restore database')) {
|
||||
return 'Jika terjadi kendala database terkunci, sistem biasanya sedang memproses transaksi. Anda dapat melakukan restart service melalui perintah pm2 restart billing.';
|
||||
}
|
||||
|
||||
// 9. Masalah Digiflazz
|
||||
if (q.includes('digiflazz') && (q.includes('gagal') || q.includes('rc 40') || q.includes('rc 41') || q.includes('rc 42') || q.includes('saldo habis'))) {
|
||||
return 'Untuk transaksi Digiflazz, pastikan IP publik server sudah didaftarkan ke whitelist IP di member area Digiflazz dan saldo deposit Anda mencukupi.';
|
||||
}
|
||||
|
||||
if (q.includes('isolir') || q.includes('putus') || q.includes('tunggak')) {
|
||||
return 'Sistem isolir berjalan otomatis sesuai tanggal jatuh tempo. Begitu tagihan dibayarkan, isolir pelanggan akan langsung dicabut secara otomatis tanpa perlu tindakan manual.';
|
||||
}
|
||||
|
||||
if (q.includes('genieacs') || q.includes('tr069') || q.includes('tr-069') || q.includes('onu') || q.includes('redaman') || q.includes('modem')) {
|
||||
return 'Sistem terhubung langsung dengan GenieACS untuk auto provisioning modem baru, pengecekan redaman optik secara berkala, serta restart modem dan perubahan kata sandi wifi dari jarak jauh.';
|
||||
}
|
||||
|
||||
if (q.includes('olt') || q.includes('snmp') || q.includes('gpon') || q.includes('epon')) {
|
||||
return 'Sistem mendukung manajemen OLT GPON dan EPON untuk memantau port PON, mendeteksi modem baru yang belum terkonfigurasi, dan monitoring redaman optik massal.';
|
||||
}
|
||||
|
||||
if (q.includes('payment') || q.includes('tripay') || q.includes('midtrans') || q.includes('xendit') || q.includes('duitku') || q.includes('qris')) {
|
||||
return 'Sistem mendukung beragam gateway pembayaran otomatis seperti QRIS, transfer bank, dan e-wallet yang langsung mencabut isolir begitu pembayaran sukses.';
|
||||
}
|
||||
|
||||
if (q.includes('whatsapp') || q.includes('wa') || q.includes('baileys') || q.includes('notif')) {
|
||||
return 'Layanan WhatsApp Gateway digunakan untuk notifikasi tagihan otomatis, pengingat jatuh tempo, dan chatbot pelanggan.';
|
||||
}
|
||||
|
||||
if (q.includes('mikrotik') || q.includes('router') || q.includes('pppoe') || q.includes('hotspot')) {
|
||||
return 'Integrasi MikroTik mendukung sinkronisasi akun PPPoE dan Hotspot, monitoring pengguna aktif, serta pemutusan sesi secara otomatis.';
|
||||
}
|
||||
|
||||
if (q.includes('radius') || q.includes('freeradius')) {
|
||||
return 'Fitur FreeRADIUS Server menyediakan autentikasi terpusat untuk akun PPPoE dan Hotspot dengan alokasi IP dinamis.';
|
||||
}
|
||||
|
||||
if (q.includes('portal') || q.includes('pelanggan') || q.includes('customer')) {
|
||||
return 'Pelanggan dapat mengakses portal mandiri untuk mengecek tagihan tanpa login, membayar via QRIS, melihat riwayat pembayaran, serta mengubah nama dan sandi wifi rumah sendiri.';
|
||||
}
|
||||
|
||||
// Jawaban standar jika belum ada keyword yang cocok
|
||||
return 'Halo, ada yang bisa saya bantu hari ini? Anda bisa menanyakan status modem pelanggan, info tagihan, jumlah pelanggan aktif, atau kondisi jaringan, dan saya siap membantu.';
|
||||
}
|
||||
|
||||
/**
|
||||
* Membersihkan jawaban AI agar tampil bersih tanpa simbol, emoji, atau header/footer formal
|
||||
*/
|
||||
function cleanAiResponse(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
let cleaned = text.trim();
|
||||
|
||||
// 1. Hapus pembungkus code block markdown
|
||||
if (cleaned.startsWith('```') && cleaned.endsWith('```')) {
|
||||
cleaned = cleaned.replace(/^```[a-zA-Z]*\n?/, '').replace(/\n?```$/, '').trim();
|
||||
}
|
||||
cleaned = cleaned.replace(/```[\s\S]*?```/g, ' ');
|
||||
cleaned = cleaned.replace(/`([^`]+)`/g, '$1');
|
||||
|
||||
// 2. Hapus header garis / separator
|
||||
cleaned = cleaned.replace(/^[─—\-_=]{2,}\s*/gm, '');
|
||||
cleaned = cleaned.replace(/\s*[─—\-_=]{2,}$/gm, '');
|
||||
|
||||
// 3. Hapus header bot / perusahaan di awal
|
||||
cleaned = cleaned.replace(/^(?:🏢|🤖|🌐|💬)?\s*\*?(?:ALIJAYA|AliJaya|RTRWNET|RTRW-?Net|Asisten AI|Asisten Virtual|Bot Billing|Portal Billing)[^\n*]*\*?\n+[─—\-_=]{2,}\n*/gi, '');
|
||||
cleaned = cleaned.replace(/^(?:🏢|🤖|🌐|💬)?\s*\*?(?:ALIJAYA|AliJaya|RTRWNET|RTRW-?Net|Asisten AI|Asisten Virtual)[^\n*]*\*?\n+/gi, '');
|
||||
|
||||
// 4. Hapus footer template atau slogan
|
||||
cleaned = cleaned.replace(/\n+[─—\-_=]{2,}\n+.*(?:Internet Tanpa Batas|AliJaya|ALIJAYA|Semoga membantu|Ada yang bisa dibantu lagi).*$/gi, '');
|
||||
cleaned = cleaned.replace(/\n+(?:Semoga membantu!?|Semoga informasinya bermanfaat!?|Ada yang bisa dibantu lagi\??|Ada hal lain yang perlu ditanyakan\??|Salam hangat,?[^\n]*)$/gi, '');
|
||||
|
||||
// 5. HAPUS SEMUA EMOJI & SIMBOL GRAFIS (lingkaran 🟢 🔴 🟡 ⚪ ⚠️ 📡 📶 💡 🤖 🧾 💳 ❌ ✅ dll.)
|
||||
cleaned = cleaned.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{200D}\u{20E3}]/gu, ' ');
|
||||
cleaned = cleaned.replace(/[🟢🔴🟡⚪⚫🟠🔵🟣🟥🟩🟦🟧🟨⭕❌✅⚠️💡🤖🧾💳📡📶🔒🔓🔄🔑👉⏱️📱🔧🏷️💾💬⚙️🛡️💰📋🙏✔️✖️◀️▶️▲▼◆■]/gu, ' ');
|
||||
|
||||
// 6. Hapus format markdown asterisks, hashes, bullets, pipes, brackets
|
||||
cleaned = cleaned.replace(/^[•·▪▫➢➔★☆✓✕✖\-\*]\s+/gm, '');
|
||||
cleaned = cleaned.replace(/[•·▪▫➢➔★☆✓✕✖]/g, ' ');
|
||||
cleaned = cleaned.replace(/[*_~#|\\^$<>\[\]{}()]/g, ' ');
|
||||
|
||||
// 7. Bersihkan spasi ganda dan baris kosong berlebih
|
||||
cleaned = cleaned.replace(/\n{3,}/g, '\n\n');
|
||||
cleaned = cleaned.replace(/[ \t]+/g, ' ');
|
||||
cleaned = cleaned.replace(/\s*([,.\?!])\s*/g, '$1 ');
|
||||
cleaned = cleaned.replace(/,\s*,+/g, ',');
|
||||
cleaned = cleaned.replace(/\.\s*\.+/g, '.');
|
||||
|
||||
return cleaned.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle tanya jawab AI utama
|
||||
*/
|
||||
async function askAi({ question, senderName, customerContext = null, isAdmin = false, extraContext = {} }) {
|
||||
const cleanQ = String(question || '').trim();
|
||||
if (!cleanQ) {
|
||||
return 'Silakan ajukan pertanyaan seputar fitur aplikasi atau layanan internet Anda.';
|
||||
}
|
||||
|
||||
let adminContext = null;
|
||||
if (isAdmin) {
|
||||
try {
|
||||
adminContext = await getAdminGlobalContext(cleanQ);
|
||||
} catch (err) {
|
||||
logger.error(`[TelegramAI] Gagal build admin context: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const provider = (getSetting('ai_provider', 'deepseek') || 'gemini').toLowerCase().trim();
|
||||
let aiReply = null;
|
||||
|
||||
if (provider === 'gemini') {
|
||||
const apiKey = getSetting('gemini_api_key', '');
|
||||
if (apiKey && apiKey.trim()) {
|
||||
try {
|
||||
aiReply = await callGeminiApi(apiKey.trim(), cleanQ, senderName, customerContext, isAdmin, adminContext);
|
||||
} catch (err) {
|
||||
logger.error(`[TelegramAI] Gagal call Gemini API: ${err.message}. Menggunakan fallback jawaban lokal.`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-provider OpenAI compatible: openai, deepseek, groq, openrouter, custom
|
||||
let apiKey = '';
|
||||
let baseUrl = '';
|
||||
let model = '';
|
||||
|
||||
if (provider === 'openai') {
|
||||
apiKey = getSetting('openai_api_key', '');
|
||||
baseUrl = 'https://api.openai.com/v1';
|
||||
model = getSetting('openai_model', 'gpt-4o-mini') || 'gpt-4o-mini';
|
||||
} else if (provider === 'deepseek') {
|
||||
apiKey = getSetting('deepseek_api_key', '');
|
||||
baseUrl = 'https://api.deepseek.com';
|
||||
model = getSetting('deepseek_model', 'deepseek-chat') || 'deepseek-chat';
|
||||
} else if (provider === 'groq') {
|
||||
apiKey = getSetting('groq_api_key', '');
|
||||
baseUrl = 'https://api.groq.com/openai/v1';
|
||||
model = getSetting('groq_model', 'llama-3.3-70b-versatile') || 'llama-3.3-70b-versatile';
|
||||
} else if (provider === 'openrouter') {
|
||||
apiKey = getSetting('openrouter_api_key', '');
|
||||
baseUrl = 'https://openrouter.ai/api/v1';
|
||||
model = getSetting('openrouter_model', 'meta-llama/llama-3.3-70b-instruct') || 'meta-llama/llama-3.3-70b-instruct';
|
||||
} else if (provider === 'custom') {
|
||||
apiKey = getSetting('custom_ai_api_key', '');
|
||||
baseUrl = getSetting('custom_ai_base_url', 'http://localhost:11434/v1');
|
||||
model = getSetting('custom_ai_model', 'llama3') || 'llama3';
|
||||
}
|
||||
|
||||
if ((apiKey && apiKey.trim()) || provider === 'custom') {
|
||||
try {
|
||||
aiReply = await callOpenAiCompatibleApi({
|
||||
apiKey: apiKey ? apiKey.trim() : (provider === 'custom' ? 'ollama' : ''),
|
||||
baseUrl,
|
||||
model,
|
||||
userQuestion: cleanQ,
|
||||
senderName,
|
||||
customerContext,
|
||||
isAdmin,
|
||||
adminContext
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error(`[TelegramAI] Gagal call AI (${provider}): ${err.message}`);
|
||||
// Fallback ke Gemini jika gemini_api_key tersedia
|
||||
const geminiKey = getSetting('gemini_api_key', '');
|
||||
if (geminiKey && geminiKey.trim()) {
|
||||
try {
|
||||
logger.info('[TelegramAI] Mencoba fallback ke Gemini API...');
|
||||
aiReply = await callGeminiApi(geminiKey.trim(), cleanQ, senderName, customerContext, isAdmin, adminContext);
|
||||
} catch (geminiErr) {
|
||||
logger.error(`[TelegramAI] Fallback ke Gemini juga gagal: ${geminiErr.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Jika API berhasil memberikan respon (baik function tool maupun teks)
|
||||
if (aiReply) {
|
||||
if (typeof aiReply === 'object' && aiReply?.type === 'function_call') {
|
||||
const toolResult = await executeAdminTool(aiReply.functionCall.name, aiReply.functionCall.args, extraContext);
|
||||
return cleanAiResponse(toolResult);
|
||||
}
|
||||
if (typeof aiReply === 'object' && aiReply?.type === 'text') {
|
||||
return cleanAiResponse(aiReply.text);
|
||||
}
|
||||
return cleanAiResponse(typeof aiReply === 'string' ? aiReply : (aiReply?.text || JSON.stringify(aiReply)));
|
||||
}
|
||||
|
||||
// Jika API key belum diatur atau semua panggilan API gagal, gunakan offline knowledge base
|
||||
return cleanAiResponse(getOfflineAnswer(cleanQ, customerContext, isAdmin, adminContext));
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitas Diagnostik: Cek DNS via DNS-over-HTTPS (DoH Cloudflare / Google)
|
||||
*/
|
||||
async function checkDns(domain, type = 'A') {
|
||||
try {
|
||||
const cleanDomain = String(domain || '').trim().replace(/^https?:\/\//i, '').split('/')[0];
|
||||
const recType = String(type || 'A').toUpperCase();
|
||||
|
||||
// Gunakan Cloudflare DoH untuk kehandalan tanpa terkena port 53 hijack ISP
|
||||
const url = `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(cleanDomain)}&type=${recType}`;
|
||||
const res = await axios.get(url, {
|
||||
headers: { 'Accept': 'application/dns-json' },
|
||||
timeout: 7000
|
||||
});
|
||||
|
||||
const data = res.data;
|
||||
if (!data.Answer || data.Answer.length === 0) {
|
||||
return `🔍 *HASIL CEK DNS*\n\nTarget: \`${cleanDomain}\`\nRecord: *${recType}*\nStatus: ⚠️ Tidak ditemukan record ${recType} untuk domain ini.`;
|
||||
}
|
||||
|
||||
let out = `🌐 *HASIL CEK DNS (Cloudflare Resolver)*\n\n`;
|
||||
out += `Domain: \`${cleanDomain}\`\nRecord: *${recType}*\n\n*Data Jawaban:*\n`;
|
||||
data.Answer.forEach(ans => {
|
||||
out += `• \`${ans.data}\` (TTL: ${ans.TTL}s)\n`;
|
||||
});
|
||||
|
||||
return out;
|
||||
} catch (err) {
|
||||
return `❌ Gagal melakukan lookup DNS: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Utilitas Diagnostik: Cek Host / Latency (HTTP Head Ping)
|
||||
*/
|
||||
async function checkPing(host) {
|
||||
try {
|
||||
let cleanHost = String(host || '').trim();
|
||||
if (!cleanHost.startsWith('http://') && !cleanHost.startsWith('https://')) {
|
||||
cleanHost = 'http://' + cleanHost;
|
||||
}
|
||||
|
||||
const startTime = Date.now();
|
||||
const res = await axios.head(cleanHost, { timeout: 8000, validateStatus: () => true });
|
||||
const latency = Date.now() - startTime;
|
||||
|
||||
return `🏓 *HASIL PING / KONEKSI*\n\n` +
|
||||
`Target: \`${cleanHost}\`\n` +
|
||||
`Status: ✅ Terhubung (HTTP ${res.status})\n` +
|
||||
`Latensi: *${latency} ms*`;
|
||||
} catch (err) {
|
||||
return `❌ Gagal menghubungi \`${host}\`: ${err.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
askAi,
|
||||
cleanAiResponse,
|
||||
checkDns,
|
||||
checkPing,
|
||||
getOfflineAnswer,
|
||||
getAdminGlobalContext
|
||||
};
|
||||
@@ -312,10 +312,25 @@ function initTelegram() {
|
||||
|
||||
bot.on('message', async (msg) => {
|
||||
if (!isAdmin(msg)) return;
|
||||
const text = msg.text;
|
||||
if (text === '/start' || text === '/menu') return; // Handled by onText
|
||||
const text = String(msg.text || '').trim();
|
||||
if (!text || text.startsWith('/')) return; // command ditangani onText
|
||||
if (text === '/start' || text === '/menu') return;
|
||||
|
||||
// Logika handle text manual jika diperlukan (misal untuk perintah kick/edit)
|
||||
try {
|
||||
bot.sendChatAction(msg.chat.id, 'typing').catch(() => {});
|
||||
const telegramAi = require('./telegramAiService');
|
||||
const answer = await telegramAi.askAi({
|
||||
question: text,
|
||||
senderName: (msg.from && msg.from.first_name) || 'Admin',
|
||||
isAdmin: true
|
||||
});
|
||||
if (!answer) return;
|
||||
const clean = typeof telegramAi.cleanAiResponse === 'function' ? telegramAi.cleanAiResponse(answer) : answer;
|
||||
await bot.sendMessage(msg.chat.id, clean, { parse_mode: 'Markdown', reply_to_message_id: msg.message_id })
|
||||
.catch(() => bot.sendMessage(msg.chat.id, clean, { reply_to_message_id: msg.message_id }));
|
||||
} catch (e) {
|
||||
logger.error('[TelegramBot] AI error: ' + e.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Callback Query Handling
|
||||
|
||||
190
services/voiceTtsService.js
Normal file
@@ -0,0 +1,190 @@
|
||||
const https = require('https');
|
||||
const { logger } = require('../config/logger');
|
||||
|
||||
// Cache audio sederhana dalam memori (maks 50 entri)
|
||||
const ttsCache = new Map();
|
||||
|
||||
/**
|
||||
* Membersihkan dan menerjemahkan format teks (markdown, emoji, simbol, istilah teknis)
|
||||
* menjadi bahasa Indonesia lisan yang santun, luwes, dan natural saat dibacakan oleh mesin suara TTS.
|
||||
*/
|
||||
function cleanTextForSpeech(text) {
|
||||
if (!text || typeof text !== 'string') return '';
|
||||
let str = text;
|
||||
|
||||
// 1. Hapus blok kode, backtick, link markdown, html tags
|
||||
str = str.replace(/```[\s\S]*?```/g, ' ');
|
||||
str = str.replace(/`([^`]+)`/g, '$1');
|
||||
str = str.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1');
|
||||
str = str.replace(/<[^>]+>/g, ' ');
|
||||
|
||||
// 2. Terjemahkan simbol mata uang & nomor
|
||||
str = str.replace(/\bRp\.?\s*([0-9.,]+)\b/gi, '$1 rupiah');
|
||||
str = str.replace(/\bNo\.\s*/gi, 'nomor ');
|
||||
str = str.replace(/\bNo\s+(\d+)/gi, 'nomor $1');
|
||||
|
||||
// 3. Terjemahkan nilai redaman optik & tanda minus angka
|
||||
str = str.replace(/-(\d+(?:\.\d+)?)\s*(?:dBm|dbm|DBM)\b/gi, 'minus $1 desibel');
|
||||
str = str.replace(/\b(\d+(?:\.\d+)?)\s*(?:dBm|dbm|DBM)\b/gi, '$1 desibel');
|
||||
str = str.replace(/(^|\s)-(\d+(?:\.\d+)?)/g, '$1minus $2');
|
||||
|
||||
// 4. Terjemahkan singkatan & istilah teknis ke bahasa percakapan santun
|
||||
str = str.replace(/\bRX\s*Power\b/gi, 'daya sinyal');
|
||||
str = str.replace(/\bTX\s*Power\b/gi, 'daya kirim');
|
||||
str = str.replace(/\bONU\b|\bONT\b/gi, 'modem');
|
||||
str = str.replace(/\bPPPoE\b/gi, 'pe pe po e');
|
||||
str = str.replace(/\bSSID\b/gi, 'nama wifi');
|
||||
str = str.replace(/\bUptime\b/gi, 'waktu aktif');
|
||||
str = str.replace(/\bIP\s*Address\b|\bIP\b/gi, 'alamat i pe');
|
||||
str = str.replace(/\bDNS\b/gi, 'de en es');
|
||||
str = str.replace(/\bAPI\b/gi, 'a pe i');
|
||||
str = str.replace(/\bWA\b/gi, 'whatsapp');
|
||||
str = str.replace(/\bQRIS\b/gi, 'kris');
|
||||
str = str.replace(/\bLOS\b/gi, 'lampu merah sinyal putus');
|
||||
str = str.replace(/\b(\d+)\s*Mbps\b/gi, '$1 mega bit per detik');
|
||||
str = str.replace(/\b(\d+)\s*Kbps\b/gi, '$1 kilo bit per detik');
|
||||
str = str.replace(/\b(\d+)\s*Gbps\b/gi, '$1 giga bit per detik');
|
||||
str = str.replace(/\b(\d+)\s*ms\b/gi, '$1 mili detik');
|
||||
|
||||
// 5. Terjemahkan simbol umum
|
||||
str = str.replace(/&/g, ' dan ');
|
||||
str = str.replace(/%/g, ' persen ');
|
||||
str = str.replace(/\+/g, ' plus ');
|
||||
str = str.replace(/=/g, ' adalah ');
|
||||
str = str.replace(/@/g, ' di ');
|
||||
str = str.replace(/\//g, ' atau ');
|
||||
|
||||
// 6. HAPUS SEMUA EMOJI & SIMBOL GRAFIS (lingkaran warna hijau, merah, kuning, dll.)
|
||||
str = str.replace(/[\u{1F300}-\u{1FAFF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}\u{1F1E6}-\u{1F1FF}\u{FE00}-\u{FE0F}\u{1F900}-\u{1F9FF}\u{1F600}-\u{1F64F}\u{1F680}-\u{1F6FF}\u{200D}\u{20E3}]/gu, ' ');
|
||||
str = str.replace(/[🟢🔴🟡⚪⚫🟠🔵🟣🟥🟩🟦🟧🟨⭕❌✅⚠️💡🤖🧾💳📡📶🔒🔓🔄🔑👉⏱️📱🔧🏷️💾💬⚙️🛡️💰📋🙏✔️✖️◀️▶️▲▼◆■]/gu, ' ');
|
||||
|
||||
// 7. Hapus markdown bullets, pembatas, dan format kaku
|
||||
str = str.replace(/^[•·▪▫➢➔★☆✓✕✖\-\*]\s+/gm, '');
|
||||
str = str.replace(/[•·▪▫➢➔★☆✓✕✖]/g, ' ');
|
||||
str = str.replace(/^[─—\-_=]{2,}\s*$/gm, '');
|
||||
str = str.replace(/[*_~#|\\^$<>\[\]{}()]/g, ' ');
|
||||
|
||||
// 8. Hapus semua karakter selain huruf, angka, spasi, dan tanda jeda suara (. , ? !)
|
||||
str = str.replace(/[^\p{L}\p{N}\s,.\?!]/gu, ' ');
|
||||
|
||||
// 9. Normalisasi jeda tanda baca dan spasi
|
||||
str = str.replace(/\s*([,.\?!])\s*/g, '$1 ');
|
||||
str = str.replace(/,\s*,+/g, ',');
|
||||
str = str.replace(/\.\s*\.+/g, '.');
|
||||
str = str.replace(/\s+/g, ' ').trim();
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
/**
|
||||
* Memecah teks panjang menjadi potongan kalimat (< 180 karakter)
|
||||
* agar pas dengan batasan API TTS tanpa memotong kata di tengah.
|
||||
*/
|
||||
function splitTextIntoChunks(text, maxLen = 180) {
|
||||
if (!text) return [];
|
||||
const sentences = text.match(/[^.!?\n,]+[.!?\n,]*/g) || [text];
|
||||
const chunks = [];
|
||||
let currentChunk = '';
|
||||
|
||||
for (const sentence of sentences) {
|
||||
const trimmed = sentence.trim();
|
||||
if (!trimmed) continue;
|
||||
|
||||
if (trimmed.length > maxLen) {
|
||||
// Jika satu kalimat saja sudah lebih dari maxLen, pecah berdasarkan kata
|
||||
const words = trimmed.split(/\s+/);
|
||||
for (const word of words) {
|
||||
if ((currentChunk + ' ' + word).trim().length > maxLen) {
|
||||
if (currentChunk.trim()) chunks.push(currentChunk.trim());
|
||||
currentChunk = word;
|
||||
} else {
|
||||
currentChunk = (currentChunk + ' ' + word).trim();
|
||||
}
|
||||
}
|
||||
} else if ((currentChunk + ' ' + trimmed).trim().length > maxLen) {
|
||||
if (currentChunk.trim()) chunks.push(currentChunk.trim());
|
||||
currentChunk = trimmed;
|
||||
} else {
|
||||
currentChunk = (currentChunk + ' ' + trimmed).trim();
|
||||
}
|
||||
}
|
||||
|
||||
if (currentChunk.trim()) {
|
||||
chunks.push(currentChunk.trim());
|
||||
}
|
||||
|
||||
return chunks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Download satu potongan audio MP3 dari Google Translate TTS (Bahasa Indonesia)
|
||||
*/
|
||||
function fetchTtsChunk(chunkText, lang = 'id') {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = `https://translate.google.com/translate_tts?ie=UTF-8&q=${encodeURIComponent(chunkText)}&tl=${lang}&client=tw-ob`;
|
||||
const req = https.get(url, {
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
|
||||
},
|
||||
timeout: 10000
|
||||
}, (res) => {
|
||||
if (res.statusCode !== 200) {
|
||||
return reject(new Error(`TTS gagal dengan status HTTP ${res.statusCode}`));
|
||||
}
|
||||
const data = [];
|
||||
res.on('data', d => data.push(d));
|
||||
res.on('end', () => resolve(Buffer.concat(data)));
|
||||
});
|
||||
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('TTS timeout setelah 10 detik'));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate audio MP3 utuh dari teks bahasa Indonesia
|
||||
*/
|
||||
async function generateIndonesianSpeech(text) {
|
||||
const clean = cleanTextForSpeech(text);
|
||||
if (!clean) return null;
|
||||
|
||||
// Cek cache
|
||||
const cacheKey = clean.toLowerCase();
|
||||
if (ttsCache.has(cacheKey)) {
|
||||
return ttsCache.get(cacheKey);
|
||||
}
|
||||
|
||||
const chunks = splitTextIntoChunks(clean, 175);
|
||||
if (chunks.length === 0) return null;
|
||||
|
||||
try {
|
||||
const bufferList = [];
|
||||
for (const chunk of chunks) {
|
||||
const buf = await fetchTtsChunk(chunk, 'id');
|
||||
bufferList.push(buf);
|
||||
}
|
||||
|
||||
const combinedBuffer = Buffer.concat(bufferList);
|
||||
|
||||
// Simpan di cache (maksimal 50 item)
|
||||
if (ttsCache.size > 50) {
|
||||
const firstKey = ttsCache.keys().next().value;
|
||||
ttsCache.delete(firstKey);
|
||||
}
|
||||
ttsCache.set(cacheKey, combinedBuffer);
|
||||
|
||||
return combinedBuffer;
|
||||
} catch (err) {
|
||||
logger.error(`[VoiceTTS] Gagal generate suara: ${err.message}`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
cleanTextForSpeech,
|
||||
splitTextIntoChunks,
|
||||
generateIndonesianSpeech
|
||||
};
|
||||
72
tests/unit/area-access.test.js
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Self-check: pembatasan wilayah teknisi & kolektor.
|
||||
* Jalankan: node tests/unit/area-access.test.js
|
||||
* DB dan modul berat di-stub supaya bisa jalan tanpa node_modules.
|
||||
*/
|
||||
const assert = require('assert');
|
||||
const path = require('path');
|
||||
|
||||
function stub(rel, exports) {
|
||||
const p = require.resolve(path.join(__dirname, '..', '..', rel));
|
||||
require.cache[p] = { id: p, filename: p, loaded: true, exports };
|
||||
}
|
||||
|
||||
const techRows = { 1: { id: 1, area: 'Area A, Area B' }, 2: { id: 2, area: '' }, 3: { id: 3, area: 'semua' } };
|
||||
const colRows = { 7: { id: 7, area: 'Area A' }, 8: { id: 8, area: '' } };
|
||||
|
||||
const db = {
|
||||
prepare(sql) {
|
||||
return {
|
||||
get(id) {
|
||||
if (/FROM technicians/.test(sql)) return techRows[Number(id)];
|
||||
if (/FROM collectors/.test(sql)) return colRows[Number(id)];
|
||||
return undefined;
|
||||
},
|
||||
all() { return []; },
|
||||
run() { return { changes: 1 }; },
|
||||
};
|
||||
},
|
||||
exec() {},
|
||||
};
|
||||
|
||||
stub('config/database', db);
|
||||
stub('config/logger', { logger: { info() {}, warn() {}, error() {}, debug() {} } });
|
||||
stub('config/settingsManager', { getCurrentDateInTimezone: () => new Date(), getSetting: () => '' });
|
||||
stub('services/customerIdService', { generateCustomerCode: () => 'C0001', backfillCustomerCodes: () => 0 });
|
||||
|
||||
const tech = require('../../services/techService');
|
||||
const cust = require('../../services/customerService');
|
||||
|
||||
// Teknisi: area dipisah koma -> dibatasi ke dua area
|
||||
const tf = tech.getTechAreaWhere(1, 'c');
|
||||
assert.strictEqual(tf.isRestricted, true);
|
||||
assert.deepStrictEqual(tf.areas, ['area a', 'area b']);
|
||||
assert.ok(tf.where.includes('IN (?, ?)'));
|
||||
|
||||
// Teknisi tanpa area / "semua" -> tidak dibatasi
|
||||
assert.strictEqual(tech.getTechAreaWhere(2).isRestricted, false);
|
||||
assert.strictEqual(tech.getTechAreaWhere(3).isRestricted, false);
|
||||
|
||||
// canTechAccessCustomer
|
||||
assert.strictEqual(tech.canTechAccessCustomer(1, { area: 'area b' }), true);
|
||||
assert.strictEqual(tech.canTechAccessCustomer(1, { area: 'area z' }), false);
|
||||
assert.strictEqual(tech.canTechAccessCustomer(1, { area: '' }), false);
|
||||
assert.strictEqual(tech.canTechAccessCustomer(2, { area: 'apa saja' }), true);
|
||||
assert.strictEqual(tech.canTechAccessCustomer(null, { area: 'area a' }), false);
|
||||
|
||||
// Kolektor: pelanggan yang ditugaskan ke kolektor lain selalu ditolak
|
||||
assert.strictEqual(cust.canCollectorAccessCustomer(7, { area: 'area a', collector_id: 0 }), true);
|
||||
assert.strictEqual(cust.canCollectorAccessCustomer(7, { area: 'area a', collector_id: 7 }), true);
|
||||
assert.strictEqual(cust.canCollectorAccessCustomer(7, { area: 'area a', collector_id: 8 }), false);
|
||||
assert.strictEqual(cust.canCollectorAccessCustomer(7, { area: 'area z', collector_id: 0 }), false);
|
||||
assert.strictEqual(cust.canCollectorAccessCustomer(8, { area: 'area z', collector_id: 0 }), true);
|
||||
|
||||
// Where kolektor memuat batas area + kolektor_id
|
||||
const cf = cust.getCollectorCustomerWhere(7, 'c');
|
||||
assert.strictEqual(cf.isRestricted, true);
|
||||
assert.deepStrictEqual(cf.params, ['area a', 7]);
|
||||
const cf2 = cust.getCollectorCustomerWhere(8, 'c');
|
||||
assert.strictEqual(cf2.isRestricted, false);
|
||||
assert.deepStrictEqual(cf2.params, [8]);
|
||||
|
||||
console.log('OK: area-access (teknisi & kolektor)');
|
||||
@@ -93,6 +93,9 @@
|
||||
<div class="act-menu">
|
||||
<button type="button" class="act-item" <%- `onclick="viewStock(${item.id}, '${item.name}')"` %>><i class="bi bi-eye"></i> <%= t('admin.inventory.detail_stock', 'Detail Stok') %></button>
|
||||
<button type="button" class="act-item success" <%- `onclick="openAddStock(${item.id}, '${item.name}')"` %>><i class="bi bi-plus-circle"></i> <%= t('admin.inventory.add_stock', 'Tambah Stok') %></button>
|
||||
<button type="button" class="act-item" <%- `onclick="openIssueStock(${item.id}, '${item.name}')"` %>><i class="bi bi-box-arrow-up"></i> Stok Keluar</button>
|
||||
<button type="button" class="act-item" <%- `onclick="openReturnStock(${item.id}, '${item.name}')"` %>><i class="bi bi-arrow-return-left"></i> Retur</button>
|
||||
<button type="button" class="act-item" <%- `onclick="openAdjustStock(${item.id}, '${item.name}')"` %>><i class="bi bi-clipboard-check"></i> Opname</button>
|
||||
<button type="button" class="act-item" <%- `onclick='openEditItem(${JSON.stringify(item)})'` %>><i class="bi bi-pencil"></i> Edit Barang</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,6 +253,76 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- MODAL: STOK KELUAR -->
|
||||
<div class="mo" id="modal-issue">
|
||||
<div class="mb">
|
||||
<div class="mh"><div class="mt">Stok Keluar</div><button class="mc" onclick="closeModal('modal-issue')">×</button></div>
|
||||
<form action="/admin/inventory/stock/out" method="POST">
|
||||
<input type="hidden" name="item_id" id="o_item_id">
|
||||
<div class="mbody">
|
||||
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="o_item_name" readonly></div>
|
||||
<div class="fg"><label class="fl">Serial Number (kosongkan untuk barang tanpa SN)</label><input class="fc" name="serial_number" list="o_serials" placeholder="Pilih / scan SN"><datalist id="o_serials"></datalist></div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="fl">Jumlah</label><input class="fc" type="number" name="quantity" value="1" min="1"></div>
|
||||
<div class="fg"><label class="fl">Tipe</label>
|
||||
<select class="fs" name="type"><option value="out">Keluar / Operasional</option><option value="assigned">Terpasang ke Pelanggan</option><option value="broken">Rusak</option><option value="loss">Hilang</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fg"><label class="fl">Penerima / Teknisi</label><input class="fc" name="recipient" placeholder="Nama teknisi / pelanggan"></div>
|
||||
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
|
||||
</div>
|
||||
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-issue')">Batal</button><button class="btn btn-p">Simpan</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL: RETUR -->
|
||||
<div class="mo" id="modal-return">
|
||||
<div class="mb">
|
||||
<div class="mh"><div class="mt">Retur Barang</div><button class="mc" onclick="closeModal('modal-return')">×</button></div>
|
||||
<form action="/admin/inventory/stock/return" method="POST">
|
||||
<input type="hidden" name="item_id" id="r_item_id">
|
||||
<div class="mbody">
|
||||
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="r_item_name" readonly></div>
|
||||
<div class="fg"><label class="fl">Serial Number (untuk unit ber-SN)</label><input class="fc" name="serial_number" placeholder="Kosongkan untuk barang tanpa SN"></div>
|
||||
<div class="form-row">
|
||||
<div class="fg"><label class="fl">Jumlah</label><input class="fc" type="number" name="quantity" value="1" min="1"></div>
|
||||
<div class="fg"><label class="fl">Kondisi</label>
|
||||
<select class="fs" name="condition"><option value="used">Bekas / Copotan</option><option value="new">Baru</option><option value="broken">Rusak</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
|
||||
</div>
|
||||
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-return')">Batal</button><button class="btn btn-p">Simpan</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL: OPNAME -->
|
||||
<div class="mo" id="modal-adjust">
|
||||
<div class="mb">
|
||||
<div class="mh"><div class="mt">Stock Opname</div><button class="mc" onclick="closeModal('modal-adjust')">×</button></div>
|
||||
<form action="/admin/inventory/stock/adjust" method="POST">
|
||||
<input type="hidden" name="item_id" id="a_item_id">
|
||||
<div class="mbody">
|
||||
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="a_item_name" readonly></div>
|
||||
<div class="fg"><label class="fl">Jumlah Fisik di Gudang</label><input class="fc" type="number" name="actual_quantity" min="0" required></div>
|
||||
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
|
||||
</div>
|
||||
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-adjust')">Batal</button><button class="btn btn-p">Simpan</button></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MODAL: DETAIL STOK -->
|
||||
<div class="mo" id="modal-detail">
|
||||
<div class="mb">
|
||||
<div class="mh"><div class="mt" id="d_title">Detail Stok</div><button class="mc" onclick="closeModal('modal-detail')">×</button></div>
|
||||
<div class="mbody" id="d_body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var I18N_INV = {
|
||||
addTitle: "<%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %>",
|
||||
@@ -288,6 +361,48 @@
|
||||
document.getElementById('modal-stock').classList.add('show');
|
||||
}
|
||||
|
||||
|
||||
function openIssueStock(itemId, itemName) {
|
||||
document.getElementById('o_item_id').value = itemId;
|
||||
document.getElementById('o_item_name').value = itemName;
|
||||
var dl = document.getElementById('o_serials');
|
||||
dl.innerHTML = '';
|
||||
fetch('/admin/api/inventory/serials/' + itemId).then(r => r.json()).then(d => {
|
||||
(d.serials || []).forEach(function (s) {
|
||||
var opt = document.createElement('option');
|
||||
opt.value = s.serial_number;
|
||||
dl.appendChild(opt);
|
||||
});
|
||||
}).catch(function () {});
|
||||
document.getElementById('modal-issue').classList.add('show');
|
||||
}
|
||||
|
||||
function openReturnStock(itemId, itemName) {
|
||||
document.getElementById('r_item_id').value = itemId;
|
||||
document.getElementById('r_item_name').value = itemName;
|
||||
document.getElementById('modal-return').classList.add('show');
|
||||
}
|
||||
|
||||
function openAdjustStock(itemId, itemName) {
|
||||
document.getElementById('a_item_id').value = itemId;
|
||||
document.getElementById('a_item_name').value = itemName;
|
||||
document.getElementById('modal-adjust').classList.add('show');
|
||||
}
|
||||
|
||||
function viewStock(itemId, itemName) {
|
||||
document.getElementById('d_title').innerText = 'Detail Stok — ' + itemName;
|
||||
var body = document.getElementById('d_body');
|
||||
body.innerHTML = 'Memuat…';
|
||||
fetch('/admin/api/inventory/item/' + itemId).then(r => r.json()).then(function (d) {
|
||||
if (!d.success) { body.innerText = d.message || 'Gagal memuat'; return; }
|
||||
var rows = (d.stockList || []).map(function (s) {
|
||||
return '<tr><td>' + (s.serial_number || '-') + '</td><td>' + s.status + '</td><td class="num">' + s.quantity + '</td><td>' + (s.customer_name || '-') + '</td></tr>';
|
||||
}).join('');
|
||||
body.innerHTML = '<table class="dtbl"><thead><tr><th>SN</th><th>Status</th><th class="num">Qty</th><th>Pelanggan</th></tr></thead><tbody>' + (rows || '<tr><td colspan="4" class="text-muted">Belum ada unit stok</td></tr>') + '</tbody></table>';
|
||||
}).catch(function () { body.innerText = 'Gagal memuat'; });
|
||||
document.getElementById('modal-detail').classList.add('show');
|
||||
}
|
||||
|
||||
function closeModal(id) { document.getElementById(id).classList.remove('show'); }
|
||||
|
||||
function filterItems(q) {
|
||||
|
||||
@@ -208,6 +208,55 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="card mb16">
|
||||
<div class="card-hd"><h6><i class="bi bi-robot"></i> AI & Self-Healing</h6></div>
|
||||
<div class="card-bd settings-grid">
|
||||
<div>
|
||||
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-cpu"></i> Provider AI</h6>
|
||||
<div class="fg">
|
||||
<label class="fl">Provider</label>
|
||||
<select class="fs" name="ai_provider">
|
||||
<% const _aiProv = String(settings.ai_provider || 'deepseek'); %>
|
||||
<option value="deepseek" <%= _aiProv === 'deepseek' ? 'selected' : '' %>>DeepSeek</option>
|
||||
<option value="openai" <%= _aiProv === 'openai' ? 'selected' : '' %>>OpenAI</option>
|
||||
<option value="groq" <%= _aiProv === 'groq' ? 'selected' : '' %>>Groq</option>
|
||||
<option value="openrouter" <%= _aiProv === 'openrouter' ? 'selected' : '' %>>OpenRouter</option>
|
||||
<option value="custom" <%= _aiProv === 'custom' ? 'selected' : '' %>>Custom (OpenAI-compatible)</option>
|
||||
</select>
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">Semua provider di atas memakai format API OpenAI (chat/completions).</div>
|
||||
</div>
|
||||
<div class="fg"><label class="fl">DeepSeek API Key</label><input class="fc" type="password" autocomplete="new-password" name="deepseek_api_key" value="<%= settings.deepseek_api_key || '' %>" placeholder="sk-..."></div>
|
||||
<div class="fg"><label class="fl">DeepSeek Model</label><input class="fc" name="deepseek_model" value="<%= settings.deepseek_model || 'deepseek-chat' %>"></div>
|
||||
<div class="fg"><label class="fl">OpenAI API Key</label><input class="fc" type="password" autocomplete="new-password" name="openai_api_key" value="<%= settings.openai_api_key || '' %>" placeholder="sk-..."></div>
|
||||
<div class="fg"><label class="fl">OpenAI Model</label><input class="fc" name="openai_model" value="<%= settings.openai_model || 'gpt-4o-mini' %>"></div>
|
||||
<div class="fg"><label class="fl">Groq API Key</label><input class="fc" type="password" autocomplete="new-password" name="groq_api_key" value="<%= settings.groq_api_key || '' %>"></div>
|
||||
<div class="fg"><label class="fl">Groq Model</label><input class="fc" name="groq_model" value="<%= settings.groq_model || 'llama-3.3-70b-versatile' %>"></div>
|
||||
<div class="fg"><label class="fl">OpenRouter API Key</label><input class="fc" type="password" autocomplete="new-password" name="openrouter_api_key" value="<%= settings.openrouter_api_key || '' %>"></div>
|
||||
<div class="fg"><label class="fl">OpenRouter Model</label><input class="fc" name="openrouter_model" value="<%= settings.openrouter_model || '' %>"></div>
|
||||
<div class="fg"><label class="fl">Custom Base URL</label><input class="fc" name="custom_ai_base_url" value="<%= settings.custom_ai_base_url || '' %>" placeholder="http://localhost:11434/v1"></div>
|
||||
<div class="fg"><label class="fl">Custom API Key</label><input class="fc" type="password" autocomplete="new-password" name="custom_ai_api_key" value="<%= settings.custom_ai_api_key || '' %>" placeholder="opsional untuk Ollama"></div>
|
||||
<div class="fg"><label class="fl">Custom Model</label><input class="fc" name="custom_ai_model" value="<%= settings.custom_ai_model || 'llama3' %>"></div>
|
||||
</div>
|
||||
<div>
|
||||
<h6 style="font-size:13px;color:var(--muted);margin-bottom:12px;text-transform:uppercase;letter-spacing:1px"><i class="bi bi-activity"></i> Auto Repair</h6>
|
||||
<div class="fg">
|
||||
<label class="fl">Jalankan Self-Healing otomatis (tiap 30 menit)</label>
|
||||
<select class="fs" name="ai_self_healing_enabled">
|
||||
<option value="false" <%= (settings.ai_self_healing_enabled === true || settings.ai_self_healing_enabled === 'true') ? '' : 'selected' %>>Nonaktif</option>
|
||||
<option value="true" <%= (settings.ai_self_healing_enabled === true || settings.ai_self_healing_enabled === 'true') ? 'selected' : '' %>>Aktif</option>
|
||||
</select>
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:4px">Saat aktif, sistem memeriksa & memperbaiki: status isolir vs tagihan, secret PPPoE hilang/tertukar profil, sesi RADIUS menggantung, dan IP modem ACS kosong.</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button type="button" class="btn btn-g" onclick="selfHeal('diagnose')"><i class="bi bi-search"></i> Diagnosa</button>
|
||||
<button type="button" class="btn btn-p" onclick="selfHeal('run')"><i class="bi bi-wrench-adjustable"></i> Perbaiki Sekarang</button>
|
||||
</div>
|
||||
<pre id="selfheal-out" class="mono" style="display:none;margin-top:12px;max-height:280px;overflow:auto;font-size:11px;white-space:pre-wrap"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mb16">
|
||||
<div class="card-hd"><h6><i class="bi bi-sliders"></i> <%= t('admin.settings.system_title', 'Pengaturan Sistem') %></h6></div>
|
||||
<div class="card-bd settings-grid">
|
||||
@@ -1187,5 +1236,20 @@ function setSearch(q) {
|
||||
document.querySelectorAll('.set-tab').forEach(function (b) { b.addEventListener('click', function () { setSetTab(this.getAttribute('data-cat')); }); });
|
||||
})();
|
||||
</script>
|
||||
|
||||
<script>
|
||||
async function selfHeal(mode) {
|
||||
const out = document.getElementById('selfheal-out');
|
||||
out.style.display = 'block';
|
||||
out.textContent = mode === 'run' ? 'Menjalankan perbaikan...' : 'Mendiagnosa...';
|
||||
try {
|
||||
const res = await fetch('/admin/api/self-heal/' + (mode === 'run' ? 'run' : 'diagnose'), { method: mode === 'run' ? 'POST' : 'GET' });
|
||||
const data = await res.json();
|
||||
out.textContent = JSON.stringify(data, null, 2);
|
||||
} catch (e) {
|
||||
out.textContent = 'Gagal: ' + e.message;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -43,7 +43,8 @@
|
||||
<a href="#features"><%= t('landing.nav.features','Keunggulan') %></a>
|
||||
<a href="/customer/check-billing"><%= t('landing.hero.check','Cek Tagihan') %></a>
|
||||
<% if (voucherOn) { %><a href="/customer/voucher"><%= t('login.voucher_nav','Voucher') %></a><% } %>
|
||||
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener">Status</a><% } %>
|
||||
<% var _statusUrl = (typeof settings !== 'undefined' && settings) ? (settings.uptime_url || settings.status_url || '') : ''; %>
|
||||
<% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener">Status</a><% } %>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button class="icon-btn" onclick="toggleTheme()" aria-label="Ganti tema"><i class="bi bi-moon-stars-fill" id="themeIcon"></i></button>
|
||||
@@ -59,7 +60,7 @@
|
||||
<a href="/customer/check-billing" onclick="toggleMenu()"><i class="bi bi-receipt"></i> <%= t('landing.hero.check','Cek Tagihan') %></a>
|
||||
<% if (voucherOn) { %><a href="/customer/voucher" onclick="toggleMenu()"><i class="bi bi-ticket-perforated"></i> <%= t('login.voucher_short','Beli Voucher') %></a><% } %>
|
||||
<a href="/customer/register" onclick="toggleMenu()"><i class="bi bi-person-plus"></i> <%= t('login.register_new','Daftar Baru') %></a>
|
||||
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %>
|
||||
<% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener" onclick="toggleMenu()"><i class="bi bi-activity"></i> Status Server</a><% } %>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
@@ -223,7 +224,7 @@
|
||||
<a href="/customer/tos"><%= t('customer.footer.tos', 'Syarat & Ketentuan') %></a>
|
||||
<a href="/customer/privacy"><%= t('customer.footer.privacy', 'Kebijakan Privasi') %></a>
|
||||
<a href="/customer/tos#refund"><%= t('customer.footer.refund', 'Kebijakan Refund') %></a>
|
||||
<% if (typeof settings !== 'undefined' && settings && settings.status_url) { %><a href="<%= settings.status_url %>" target="_blank" rel="noopener"><%= t('login.status_full','Status Server & Uptime') %></a><% } %>
|
||||
<% if (_statusUrl) { %><a href="<%= _statusUrl %>" target="_blank" rel="noopener"><%= t('login.status_full','Status Server & Uptime') %></a><% } %>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||