Files
open-isp/config/database.js
T

1117 lines
46 KiB
JavaScript

/**
* Inisialisasi database SQLite untuk billing OPEN-ISP
*/
const Database = require('better-sqlite3');
const path = require('path');
const fs = require('fs');
const dbDir = path.join(__dirname, '../database');
if (!fs.existsSync(dbDir)) fs.mkdirSync(dbDir, { recursive: true });
const dbPath = path.join(dbDir, 'billing.db');
// Terapkan restore DB yang ditunda SEBELUM koneksi dibuka.
// (menimpa billing.db di bawah koneksi hidup bisa merusak DB karena WAL bercampur)
const _pendingRestore = path.join(dbDir, '.restore-pending');
if (fs.existsSync(_pendingRestore)) {
try {
const _bkName = fs.readFileSync(_pendingRestore, 'utf8').trim();
const _bkPath = path.join(__dirname, '..', 'backups', _bkName);
if (_bkName && fs.existsSync(_bkPath)) {
fs.copyFileSync(_bkPath, dbPath);
for (const _ext of ['-wal', '-shm']) { try { fs.unlinkSync(dbPath + _ext); } catch (e) {} }
console.log('[DB] Restore tertunda diterapkan dari ' + _bkName);
} else {
console.error('[DB] Restore tertunda dilewati, backup tidak ada: ' + _bkName);
}
} catch (e) {
console.error('[DB] Gagal menerapkan restore tertunda:', e.message);
}
try { fs.unlinkSync(_pendingRestore); } catch (e) {}
}
let db;
try {
db = new Database(dbPath);
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
db.pragma('cache_size = -64000'); // 64MB page cache (negatif = KB)
db.pragma('synchronous = NORMAL'); // Lebih cepat dari FULL, masih aman dengan WAL
db.pragma('temp_store = MEMORY'); // Tabel temp di RAM
db.pragma('mmap_size = 134217728'); // Memory-mapped I/O 128MB
db.pragma('busy_timeout = 5000'); // Tunggu 5 detik jika DB locked sebelum error
// Menambahkan fungsi waktu lokal untuk SQLite sesuai setting timezone
db.function('NOW_LOCAL', () => {
const { getSetting } = require('./settingsManager');
const tz = getSetting('timezone', 'Asia/Jakarta');
const now = new Date();
// Format: YYYY-MM-DD HH:mm:ss
const options = {
timeZone: tz,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
};
const formatter = new Intl.DateTimeFormat('en-US', options);
const parts = formatter.formatToParts(now);
const p = {};
parts.forEach(part => p[part.type] = part.value);
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}:${p.second}`;
});
} catch (err) {
console.error('[DB] Gagal membuka database:', err.message);
process.exit(1);
}
db.exec(`
CREATE TABLE IF NOT EXISTS areas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS expense_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
parent_id INTEGER REFERENCES expense_categories(id),
description TEXT,
icon TEXT DEFAULT 'bi bi-tag',
color TEXT DEFAULT '#6366f1',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS expenses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
category TEXT NOT NULL,
subcategory TEXT,
amount INTEGER NOT NULL,
description TEXT NOT NULL,
vendor TEXT,
receipt_number TEXT,
payment_method TEXT DEFAULT 'cash',
recorded_by_role TEXT,
recorded_by_name TEXT,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS cash_in (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date DATE NOT NULL,
category TEXT NOT NULL,
amount INTEGER NOT NULL,
description TEXT,
reference_type TEXT,
reference_id INTEGER,
receipt_number TEXT,
payment_method TEXT DEFAULT 'cash',
recorded_by_role TEXT,
recorded_by_name TEXT,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
price INTEGER NOT NULL DEFAULT 0,
speed_down INTEGER DEFAULT 0,
speed_up INTEGER DEFAULT 0,
description TEXT DEFAULT '',
billing_type TEXT DEFAULT 'postpaid',
duration_days INTEGER DEFAULT 30,
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS customers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nik TEXT DEFAULT '',
name TEXT NOT NULL,
phone TEXT DEFAULT '',
address TEXT DEFAULT '',
area TEXT DEFAULT '',
package_id INTEGER REFERENCES packages(id) ON DELETE SET NULL,
genieacs_tag TEXT DEFAULT '',
pppoe_username TEXT DEFAULT '',
isolir_profile TEXT DEFAULT 'isolir',
status TEXT DEFAULT 'active',
install_date DATE,
expired_at DATETIME DEFAULT NULL,
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS technicians (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
phone TEXT DEFAULT '',
area TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS cashiers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
phone TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS collectors (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT UNIQUE NOT NULL,
password TEXT NOT NULL,
name TEXT NOT NULL,
phone TEXT DEFAULT '',
area TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
auto_approve INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS collector_payment_requests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
collector_id INTEGER NOT NULL REFERENCES collectors(id) ON DELETE CASCADE,
invoice_id INTEGER NOT NULL REFERENCES invoices(id) ON DELETE CASCADE,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
amount INTEGER NOT NULL DEFAULT 0,
note TEXT DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending', -- pending, approved, rejected
decided_by_role TEXT DEFAULT '', -- admin, cashier
decided_by_name TEXT DEFAULT '',
decided_note TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL()),
decided_at DATETIME
);
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
period_month INTEGER NOT NULL,
period_year INTEGER NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
status TEXT DEFAULT 'unpaid',
paid_at DATETIME,
paid_by_name TEXT DEFAULT '',
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS discount_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER,
customer_id INTEGER,
period_month INTEGER,
period_year INTEGER,
discount_type TEXT,
discount_value REAL,
discount_amount REAL,
amount_before REAL,
amount_after REAL,
applied_by TEXT DEFAULT '',
note TEXT DEFAULT '',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
subject TEXT NOT NULL,
message TEXT NOT NULL,
status TEXT DEFAULT 'open', -- open, in_progress, resolved
technician_id INTEGER REFERENCES technicians(id) ON DELETE SET NULL,
technician_notes TEXT DEFAULT '',
photos TEXT DEFAULT '', -- JSON array of photo paths
photo_metadata TEXT DEFAULT '', -- JSON array of metadata (timestamp, gps, etc)
customer_photos TEXT DEFAULT '', -- JSON array of customer uploaded photos
customer_photo_metadata TEXT DEFAULT '', -- JSON array of customer photo metadata
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS routers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
host TEXT NOT NULL,
port INTEGER DEFAULT 8728,
user TEXT NOT NULL,
password TEXT NOT NULL,
description TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS promo_slides (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT DEFAULT '',
description TEXT DEFAULT '',
image_path TEXT NOT NULL,
url TEXT DEFAULT '',
open_in_new_tab INTEGER DEFAULT 0,
sort_order INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
start_date DATE,
end_date DATE,
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS olts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
host TEXT NOT NULL,
snmp_community TEXT DEFAULT 'public',
snmp_port INTEGER DEFAULT 161,
brand TEXT DEFAULT 'zte', -- zte, huawei, vsol, hioso, hsqg, etc.
description TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS odps (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
olt_id INTEGER REFERENCES olts(id) ON DELETE SET NULL,
pon_port TEXT DEFAULT '',
port_capacity INTEGER NOT NULL DEFAULT 16,
lat TEXT,
lng TEXT,
description TEXT,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS voucher_batches (
id INTEGER PRIMARY KEY AUTOINCREMENT,
router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL,
profile_name TEXT NOT NULL,
qty_total INTEGER NOT NULL DEFAULT 0,
qty_created INTEGER NOT NULL DEFAULT 0,
qty_failed INTEGER NOT NULL DEFAULT 0,
price INTEGER NOT NULL DEFAULT 0,
validity TEXT DEFAULT '',
prefix TEXT DEFAULT '',
code_length INTEGER NOT NULL DEFAULT 4,
status TEXT DEFAULT 'creating',
created_by TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS vouchers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
batch_id INTEGER NOT NULL REFERENCES voucher_batches(id) ON DELETE CASCADE,
router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL,
code TEXT NOT NULL,
password TEXT NOT NULL,
profile_name TEXT NOT NULL,
comment TEXT DEFAULT '',
status TEXT DEFAULT 'pending',
used_at DATETIME,
last_seen_comment TEXT DEFAULT '',
last_seen_uptime TEXT DEFAULT '',
last_seen_at DATETIME,
created_at DATETIME DEFAULT (NOW_LOCAL()),
UNIQUE(router_id, code)
);
CREATE TABLE IF NOT EXISTS public_voucher_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL,
profile_name TEXT NOT NULL,
validity TEXT DEFAULT '',
price INTEGER NOT NULL DEFAULT 0,
buyer_phone TEXT NOT NULL,
status TEXT DEFAULT 'pending',
paid_at DATETIME,
fulfilled_at DATETIME,
voucher_code TEXT DEFAULT '',
voucher_password TEXT DEFAULT '',
voucher_comment TEXT DEFAULT '',
wa_sent INTEGER NOT NULL DEFAULT 0,
wa_sent_at DATETIME,
wa_error TEXT DEFAULT '',
payment_gateway TEXT DEFAULT '',
payment_order_id TEXT DEFAULT '',
payment_link TEXT DEFAULT '',
payment_reference TEXT DEFAULT '',
payment_payload TEXT,
payment_expires_at DATETIME,
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS app_settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS webhook_payment_notifs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
service TEXT DEFAULT '',
content TEXT NOT NULL,
parsed_amount INTEGER,
parsed_ok INTEGER NOT NULL DEFAULT 0,
matched_invoice_id INTEGER,
matched_voucher_order_id INTEGER,
ip TEXT DEFAULT '',
user_agent TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_voucher_batches_router ON voucher_batches(router_id);
CREATE INDEX IF NOT EXISTS idx_vouchers_batch ON vouchers(batch_id);
CREATE INDEX IF NOT EXISTS idx_vouchers_code ON vouchers(code);
CREATE INDEX IF NOT EXISTS idx_public_voucher_orders_status ON public_voucher_orders(status);
CREATE INDEX IF NOT EXISTS idx_public_voucher_orders_created ON public_voucher_orders(created_at);
CREATE INDEX IF NOT EXISTS idx_collectors_username ON collectors(username);
CREATE INDEX IF NOT EXISTS idx_collector_pay_req_status ON collector_payment_requests(status);
CREATE INDEX IF NOT EXISTS idx_collector_pay_req_invoice ON collector_payment_requests(invoice_id);
CREATE INDEX IF NOT EXISTS idx_collector_pay_req_collector ON collector_payment_requests(collector_id);
CREATE INDEX IF NOT EXISTS idx_collector_pay_req_created ON collector_payment_requests(created_at);
CREATE INDEX IF NOT EXISTS idx_webhook_payment_notifs_created ON webhook_payment_notifs(created_at);
CREATE INDEX IF NOT EXISTS idx_webhook_payment_notifs_service ON webhook_payment_notifs(service);
-- ─── INVENTORY / WAREHOUSE ───────────────────────────────────────────
CREATE TABLE IF NOT EXISTS inventory_categories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
description TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS inventory_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
category_id INTEGER REFERENCES inventory_categories(id) ON DELETE SET NULL,
name TEXT NOT NULL,
brand TEXT DEFAULT '',
model TEXT DEFAULT '',
unit TEXT DEFAULT 'pcs', -- pcs, meter, roll, etc.
min_stock INTEGER DEFAULT 5,
description TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS inventory_stock (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL REFERENCES inventory_items(id) ON DELETE CASCADE,
serial_number TEXT UNIQUE, -- Optional, for items like ONT/Router
quantity INTEGER NOT NULL DEFAULT 0,
condition TEXT DEFAULT 'new', -- new, used, broken
location TEXT DEFAULT 'Gudang Utama',
status TEXT DEFAULT 'available', -- available, assigned, broken, lost
assigned_to_customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
note TEXT DEFAULT '',
updated_at DATETIME DEFAULT (NOW_LOCAL()),
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS inventory_logs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER REFERENCES inventory_items(id) ON DELETE SET NULL,
stock_id INTEGER REFERENCES inventory_stock(id) ON DELETE SET NULL,
type TEXT NOT NULL, -- in (stock masuk), out (stock keluar/dipakai), adjust (penyesuaian), broken, return
quantity INTEGER NOT NULL DEFAULT 0,
actor TEXT DEFAULT 'Admin',
note TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_inventory_items_cat ON inventory_items(category_id);
CREATE INDEX IF NOT EXISTS idx_inventory_stock_item ON inventory_stock(item_id);
CREATE INDEX IF NOT EXISTS idx_inventory_stock_sn ON inventory_stock(serial_number);
CREATE INDEX IF NOT EXISTS idx_inventory_logs_item ON inventory_logs(item_id);
CREATE INDEX IF NOT EXISTS idx_inventory_logs_created ON inventory_logs(created_at);
CREATE TABLE IF NOT EXISTS genieacs_servers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
url TEXT NOT NULL,
cwmp_url TEXT,
username TEXT,
password TEXT,
location TEXT DEFAULT '',
status TEXT DEFAULT 'active',
device_count INTEGER DEFAULT 0,
last_sync DATETIME,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS wa_chat_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
direction TEXT NOT NULL DEFAULT 'outbound',
gateway TEXT NOT NULL DEFAULT 'gowa',
sender_phone TEXT,
recipient_phone TEXT,
customer_id INTEGER REFERENCES customers(id) ON DELETE SET NULL,
customer_name TEXT,
message_text TEXT NOT NULL,
status TEXT DEFAULT 'sent',
meta_message_id TEXT,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_wa_chat_messages_phone ON wa_chat_messages(sender_phone, recipient_phone);
CREATE INDEX IF NOT EXISTS idx_wa_chat_messages_created ON wa_chat_messages(created_at);
`);
// Inisialisasi tabel voucher_packages (Paket Voucher Hotspot Real-time)
db.exec(`
CREATE TABLE IF NOT EXISTS voucher_packages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL,
profile_name TEXT NOT NULL,
price INTEGER NOT NULL DEFAULT 0,
validity TEXT DEFAULT '',
prefix TEXT DEFAULT '',
code_length INTEGER NOT NULL DEFAULT 6,
charset TEXT DEFAULT 'mixed',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL()),
UNIQUE(router_id, profile_name)
);
CREATE TABLE IF NOT EXISTS radius_nas (
id INTEGER PRIMARY KEY AUTOINCREMENT,
nasname TEXT NOT NULL UNIQUE,
shortname TEXT DEFAULT '',
secret TEXT NOT NULL,
description TEXT DEFAULT '',
is_active INTEGER DEFAULT 1,
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS radius_accounting (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL,
nas_ip TEXT DEFAULT '',
framed_ip TEXT DEFAULT '',
session_id TEXT NOT NULL,
status_type INTEGER DEFAULT 1,
input_octets INTEGER DEFAULT 0,
output_octets INTEGER DEFAULT 0,
input_gigawords INTEGER DEFAULT 0,
output_gigawords INTEGER DEFAULT 0,
session_time INTEGER DEFAULT 0,
terminate_cause INTEGER DEFAULT 0,
calling_station_id TEXT DEFAULT '',
called_station_id TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_radius_acct_user ON radius_accounting(username);
CREATE INDEX IF NOT EXISTS idx_radius_acct_session ON radius_accounting(session_id);
CREATE INDEX IF NOT EXISTS idx_radius_acct_status ON radius_accounting(status_type);
`);
/**
* Memastikan menu-menu utama (WA, Settings, dll) selalu terbuka (Visible)
* meskipun setelah update dari GitHub yang mungkin mengunci menu tersebut secara default.
*/
function forceUnlockCoreMenus() {
try {
const SETTINGS_KEY = 'sidebar_menu_states';
const KEYS_KEY = 'sidebar_activation_keys';
// Ambil status menu saat ini dari DB
const rowStates = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(SETTINGS_KEY);
const rowKeys = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(KEYS_KEY);
let states = rowStates ? JSON.parse(rowStates.value) : {};
let keys = rowKeys ? JSON.parse(rowKeys.value) : {};
const hasGit = fs.existsSync(path.join(__dirname, '..', '.git'));
const coreMenus = ['mikrotik', 'radius_server', 'whatsapp', 'broadcast', 'settings', 'backup', 'monitoring', 'audit_logs'];
if (hasGit) coreMenus.push('update');
const passwordHash = 'af03458614be223016c5bc1c0788eaefc6a6bfd0c11cdcd4bc520f49d0cb474a'; // hash kode aktivasi default — plaintext hanya di settings.json (activation_code)
const crypto = require('crypto');
function sha256(input) {
return crypto.createHash('sha256').update(String(input || '')).digest('hex');
}
let changed = false;
for (const menu of coreMenus) {
// Paksa status jadi visible
if (states[menu] !== 'visible') {
states[menu] = 'visible';
changed = true;
}
// Pastikan ada kunci aktivasi yang valid agar kode lama tetap membukanya
const validKey = sha256(menu + passwordHash);
if (keys[menu] !== validKey) {
keys[menu] = validKey;
changed = true;
}
}
if (!hasGit && states['update'] !== 'hidden') { states['update'] = 'hidden'; changed = true; }
if (changed) {
const now = new Date().toISOString();
db.prepare('INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)').run(SETTINGS_KEY, JSON.stringify(states), now);
db.prepare('INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, ?)').run(KEYS_KEY, JSON.stringify(keys), now);
console.log('[DB] Core menus have been force-unlocked.');
}
} catch (e) {
console.error('[DB] Gagal force unlock core menus:', e.message);
}
}
// Jalankan force unlock setiap kali database diinisialisasi
forceUnlockCoreMenus();
// Tabel voucher diskon (dipakai voucherService) — WAJIB utk instalasi baru
try {
db.exec(`CREATE TABLE IF NOT EXISTS discount_vouchers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
code TEXT UNIQUE NOT NULL,
type TEXT DEFAULT 'nominal',
value INTEGER DEFAULT 0,
max_discount INTEGER DEFAULT 0,
min_purchase INTEGER DEFAULT 0,
active INTEGER DEFAULT 1,
max_uses INTEGER DEFAULT 0,
used_count INTEGER DEFAULT 0,
note TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL())
)`);
} catch (e) { /* ignore */ }
// Tambahkan kolom baru jika belum ada
// Kolom pendaftaran (reg_*) — WAJIB utk instalasi baru (fresh DB), mis. "no such column: reg_status"
try { db.exec("ALTER TABLE customers ADD COLUMN reg_voucher_code TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE customers ADD COLUMN reg_install_fee INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE customers ADD COLUMN reg_prorata INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE customers ADD COLUMN reg_discount INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE customers ADD COLUMN reg_total INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE customers ADD COLUMN reg_due_date TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE customers ADD COLUMN reg_status TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ }
// Kolom invoices (pembayaran/QRIS/diskon/void) — WAJIB utk instalasi baru
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_gateway TEXT"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_order_id TEXT"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_link TEXT"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_reference TEXT"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_payload TEXT"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_expires_at DATETIME"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN qris_unique_code INTEGER"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN qris_amount_unique INTEGER"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN qris_assigned_at DATETIME"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN qris_paid_notif_id INTEGER"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN discount_type TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN discount_value REAL DEFAULT 0"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN discount_amount INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN amount_before_discount INTEGER DEFAULT 0"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN voided_at DATETIME"); } catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE invoices ADD COLUMN void_reason TEXT DEFAULT ''"); } catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN auto_isolate INTEGER DEFAULT 1");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN isolate_day INTEGER DEFAULT 10");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN email TEXT DEFAULT ''");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN olt_id INTEGER REFERENCES olts(id) ON DELETE SET NULL");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN pon_port TEXT DEFAULT ''");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN odp_id INTEGER REFERENCES odps(id) ON DELETE SET NULL");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN lat TEXT");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN lng TEXT");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN cable_path TEXT");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN connection_type TEXT DEFAULT 'pppoe'");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN static_ip TEXT");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN mac_address TEXT");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN hotspot_username TEXT DEFAULT ''");
} catch (e) {}
try {
db.exec("ALTER TABLE customers ADD COLUMN hotspot_password TEXT DEFAULT ''");
} catch (e) {}
try {
db.exec("ALTER TABLE customers ADD COLUMN hotspot_profile TEXT DEFAULT ''");
} catch (e) {}
try {
db.exec("ALTER TABLE customers ADD COLUMN pppoe_password TEXT DEFAULT ''");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN pppoe_remote_address TEXT DEFAULT ''");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN wifi_ssid TEXT DEFAULT ''");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN password_wifi TEXT DEFAULT ''");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN nik TEXT DEFAULT ''");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE customers ADD COLUMN collector_id INTEGER REFERENCES collectors(id) ON DELETE SET NULL");
} catch (e) { /* ignore if already exists */ }
try {
db.exec("ALTER TABLE collectors ADD COLUMN auto_approve INTEGER DEFAULT 0");
} catch (e) { /* ignore if already exists */ }
try { db.exec("ALTER TABLE odps ADD COLUMN port_capacity INTEGER NOT NULL DEFAULT 16"); } catch (e) { /* ignore if already exists */ }
// Kolom untuk PPN & ULO/USO pada tabel packages
try { db.exec("ALTER TABLE packages ADD COLUMN use_ppn INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN ppn_percentage REAL DEFAULT 11.0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN use_uso INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN uso_percentage REAL DEFAULT 1.75"); } catch (e) {}
// Kolom untuk Prepaid / Prabayar pada packages dan customers
try { db.exec("ALTER TABLE packages ADD COLUMN billing_type TEXT DEFAULT 'postpaid'"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN duration_days INTEGER DEFAULT 30"); } catch (e) {}
try { db.exec("ALTER TABLE customers ADD COLUMN expired_at DATETIME DEFAULT NULL"); } catch (e) {}
// Kolom untuk Tiket Bantuan (Foto & Catatan Teknisi)
try { db.exec("ALTER TABLE tickets ADD COLUMN technician_notes TEXT DEFAULT ''"); } catch (e) {}
try { db.exec("ALTER TABLE tickets ADD COLUMN photos TEXT DEFAULT ''"); } catch (e) {}
try { db.exec("ALTER TABLE tickets ADD COLUMN photo_metadata TEXT DEFAULT ''"); } catch (e) {}
try { db.exec("ALTER TABLE tickets ADD COLUMN customer_photos TEXT DEFAULT ''"); } catch (e) {}
try { db.exec("ALTER TABLE tickets ADD COLUMN customer_photo_metadata TEXT DEFAULT ''"); } catch (e) {}
// Kolom untuk Payment Gateway di tabel invoices
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_gateway TEXT"); } catch (e) {}
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_order_id TEXT"); } catch (e) {}
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_link TEXT"); } catch (e) {}
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_reference TEXT"); } catch (e) {}
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_payload TEXT"); } catch (e) {}
try { db.exec("ALTER TABLE invoices ADD COLUMN payment_expires_at DATETIME"); } catch (e) {}
// Kolom untuk QRIS statis (semi-otomatis via nominal unik)
try { db.exec("ALTER TABLE invoices ADD COLUMN qris_unique_code INTEGER"); } catch (e) {}
try { db.exec("ALTER TABLE invoices ADD COLUMN qris_amount_unique INTEGER"); } catch (e) {}
try { db.exec("ALTER TABLE invoices ADD COLUMN qris_assigned_at DATETIME"); } catch (e) {}
try { db.exec("ALTER TABLE invoices ADD COLUMN qris_paid_notif_id INTEGER"); } catch (e) {}
// Kolom untuk QRIS statis pada voucher publik
try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN qris_unique_code INTEGER"); } catch (e) {}
try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN qris_amount_unique INTEGER"); } catch (e) {}
try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN qris_assigned_at DATETIME"); } catch (e) {}
try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN qris_paid_notif_id INTEGER"); } catch (e) {}
try { db.exec("ALTER TABLE public_voucher_orders ADD COLUMN proof_url TEXT DEFAULT ''"); } catch (e) {}
// Kolom untuk Login OLT (Web/API)
try { db.exec("ALTER TABLE olts ADD COLUMN web_user TEXT DEFAULT 'admin'"); } catch (e) {}
try { db.exec("ALTER TABLE olts ADD COLUMN web_password TEXT DEFAULT 'admin'"); } catch (e) {}
try { db.exec("ALTER TABLE olts ADD COLUMN api_base_url TEXT"); } catch (e) {}
try { db.exec("ALTER TABLE olts ADD COLUMN telnet_port INTEGER DEFAULT 23"); } catch (e) {}
try { db.exec("ALTER TABLE olts ADD COLUMN enable_password TEXT"); } catch (e) {}
try { db.exec("ALTER TABLE voucher_batches ADD COLUMN updated_at DATETIME DEFAULT (NOW_LOCAL())"); } catch (e) {}
try { db.exec("ALTER TABLE vouchers ADD COLUMN last_seen_comment TEXT DEFAULT ''"); } catch (e) {}
try { db.exec("ALTER TABLE vouchers ADD COLUMN last_seen_uptime TEXT DEFAULT ''"); } catch (e) {}
try { db.exec("ALTER TABLE vouchers ADD COLUMN last_seen_at DATETIME"); } catch (e) {}
try { db.exec("ALTER TABLE voucher_batches ADD COLUMN mode TEXT DEFAULT 'voucher'"); } catch (e) {}
try { db.exec("ALTER TABLE voucher_batches ADD COLUMN charset TEXT DEFAULT 'numbers'"); } catch (e) {}
// Relasi notifikasi webhook → invoice (untuk audit)
try { db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_invoice_id INTEGER"); } catch (e) {}
try { db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_voucher_order_id INTEGER"); } catch (e) {}
// Kolom untuk Dynamic Speed & FUP di tabel packages
try { db.exec("ALTER TABLE packages ADD COLUMN night_speed_down INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN night_speed_up INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN fup_limit_gb INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN fup_speed_down INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN use_night_speed INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN night_profile_name TEXT"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN use_fup INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN fup_profile_name TEXT"); } catch (e) {}
// Promo harga & prorata tagihan pertama (per paket + counter per pelanggan)
try { db.exec("ALTER TABLE packages ADD COLUMN promo_price INTEGER"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN promo_cycles INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN prorate_first_invoice INTEGER DEFAULT 0"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN router_id INTEGER REFERENCES routers(id) ON DELETE SET NULL"); } catch (e) {}
try { db.exec("ALTER TABLE packages ADD COLUMN router_id INTEGER"); } catch (e) {}
try { db.exec("ALTER TABLE customers ADD COLUMN promo_cycles_used INTEGER DEFAULT 0"); } catch (e) {}
// Tabel untuk Tracking Pemakaian (Usage) Pelanggan
db.exec(`
CREATE TABLE IF NOT EXISTS customer_usage (
id INTEGER PRIMARY KEY AUTOINCREMENT,
customer_id INTEGER NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
period_month INTEGER NOT NULL,
period_year INTEGER NOT NULL,
bytes_in INTEGER DEFAULT 0,
bytes_out INTEGER DEFAULT 0,
last_total_bytes_in INTEGER DEFAULT 0, -- Untuk menghitung delta
last_total_bytes_out INTEGER DEFAULT 0,
updated_at DATETIME DEFAULT (NOW_LOCAL()),
UNIQUE(customer_id, period_month, period_year)
);
CREATE INDEX IF NOT EXISTS idx_usage_customer ON customer_usage(customer_id);
CREATE INDEX IF NOT EXISTS idx_usage_period ON customer_usage(period_month, period_year);
`);
db.exec(`
`);
db.exec(`
`);
// ─── ATTENDANCE / ABSENSI KARYAWAN ───────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS attendance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_type TEXT NOT NULL, -- technician, admin, cashier, collector
employee_id INTEGER NOT NULL,
employee_name TEXT NOT NULL,
check_in_time DATETIME NOT NULL,
check_in_lat TEXT DEFAULT '',
check_in_lng TEXT DEFAULT '',
check_in_note TEXT DEFAULT '',
check_in_photo TEXT DEFAULT '', -- Path to check-in photo
check_out_time DATETIME,
check_out_lat TEXT DEFAULT '',
check_out_lng TEXT DEFAULT '',
check_out_note TEXT DEFAULT '',
check_out_photo TEXT DEFAULT '', -- Path to check-out photo
work_duration_minutes INTEGER DEFAULT 0,
status TEXT DEFAULT 'checked_in', -- checked_in, checked_out
created_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_attendance_employee ON attendance(employee_type, employee_id);
CREATE INDEX IF NOT EXISTS idx_attendance_date ON attendance(date(check_in_time));
CREATE INDEX IF NOT EXISTS idx_attendance_status ON attendance(status);
`);
try {
const attendanceColumns = db.prepare('PRAGMA table_info(attendance)').all().map(c => c.name);
if (!attendanceColumns.includes('check_in_photo')) {
db.exec("ALTER TABLE attendance ADD COLUMN check_in_photo TEXT DEFAULT ''");
}
if (!attendanceColumns.includes('check_out_photo')) {
db.exec("ALTER TABLE attendance ADD COLUMN check_out_photo TEXT DEFAULT ''");
}
} catch (e) {}
/**
* Helper untuk App Settings (Database)
*/
const getAppSetting = (key, defaultValue = null) => {
try {
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key);
if (!row) return defaultValue;
try { return JSON.parse(row.value); } catch { return row.value; }
} catch (e) {
return defaultValue;
}
};
const saveAppSetting = (key, value) => {
try {
const jsonValue = JSON.stringify(value);
db.prepare('INSERT OR REPLACE INTO app_settings (key, value, updated_at) VALUES (?, ?, (NOW_LOCAL()))').run(key, jsonValue);
return true;
} catch (e) {
console.error(`[DB] Error saving setting ${key}:`, e.message);
return false;
}
};
// ─── PAYROLL / GAJI KARYAWAN ─────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS payroll_settings (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_type TEXT NOT NULL,
employee_id INTEGER NOT NULL,
base_salary INTEGER DEFAULT 0,
transport_allowance INTEGER DEFAULT 0,
meal_allowance INTEGER DEFAULT 0,
phone_allowance INTEGER DEFAULT 0,
other_allowance INTEGER DEFAULT 0,
other_allowance_note TEXT DEFAULT '',
absence_deduction_per_day INTEGER DEFAULT 0,
bonus_per_ticket INTEGER DEFAULT 0,
commission_percentage REAL DEFAULT 0,
working_days_per_month INTEGER DEFAULT 26,
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL()),
UNIQUE(employee_type, employee_id)
);
CREATE TABLE IF NOT EXISTS payroll_slips (
id INTEGER PRIMARY KEY AUTOINCREMENT,
employee_type TEXT NOT NULL,
employee_id INTEGER NOT NULL,
employee_name TEXT NOT NULL,
period_month INTEGER NOT NULL,
period_year INTEGER NOT NULL,
base_salary INTEGER DEFAULT 0,
transport_allowance INTEGER DEFAULT 0,
meal_allowance INTEGER DEFAULT 0,
phone_allowance INTEGER DEFAULT 0,
other_allowance INTEGER DEFAULT 0,
other_allowance_note TEXT DEFAULT '',
working_days INTEGER DEFAULT 0,
absent_days INTEGER DEFAULT 0,
late_days INTEGER DEFAULT 0,
overtime_hours REAL DEFAULT 0,
total_tickets_resolved INTEGER DEFAULT 0,
total_collection_amount INTEGER DEFAULT 0,
ticket_bonus INTEGER DEFAULT 0,
collection_commission INTEGER DEFAULT 0,
overtime_bonus INTEGER DEFAULT 0,
absence_deduction INTEGER DEFAULT 0,
late_deduction INTEGER DEFAULT 0,
other_deduction INTEGER DEFAULT 0,
other_deduction_note TEXT DEFAULT '',
gross_salary INTEGER DEFAULT 0,
total_deductions INTEGER DEFAULT 0,
net_salary INTEGER DEFAULT 0,
status TEXT DEFAULT 'draft',
approved_at DATETIME,
paid_at DATETIME,
notes TEXT DEFAULT '',
created_at DATETIME DEFAULT (NOW_LOCAL()),
UNIQUE(employee_type, employee_id, period_month, period_year)
);
CREATE INDEX IF NOT EXISTS idx_payroll_settings_emp ON payroll_settings(employee_type, employee_id);
CREATE INDEX IF NOT EXISTS idx_payroll_slips_emp ON payroll_slips(employee_type, employee_id);
CREATE INDEX IF NOT EXISTS idx_payroll_slips_period ON payroll_slips(period_month, period_year);
CREATE INDEX IF NOT EXISTS idx_payroll_slips_status ON payroll_slips(status);
`);
// ─── BUILT-IN ACS (TR-069) ──────────────────────────────────────────────────
db.exec(`
CREATE TABLE IF NOT EXISTS acs_devices (
id TEXT PRIMARY KEY,
serial_number TEXT,
manufacturer TEXT,
product_class TEXT,
oui TEXT,
software_version TEXT,
hardware_version TEXT,
ip_address TEXT,
connection_request_url TEXT,
connection_request_user TEXT,
connection_request_pass TEXT,
tags TEXT DEFAULT '[]',
params TEXT DEFAULT '{}',
last_inform DATETIME,
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE TABLE IF NOT EXISTS acs_tasks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
device_id TEXT NOT NULL,
name TEXT NOT NULL,
payload TEXT DEFAULT '{}',
status TEXT DEFAULT 'pending',
result TEXT,
retry_count INTEGER DEFAULT 0,
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_acs_devices_sn ON acs_devices(serial_number);
CREATE INDEX IF NOT EXISTS idx_acs_devices_inform ON acs_devices(last_inform);
CREATE INDEX IF NOT EXISTS idx_acs_tasks_device ON acs_tasks(device_id);
CREATE INDEX IF NOT EXISTS idx_acs_tasks_status ON acs_tasks(status);
`);
// Tambahkan kategori pengeluaran default jika belum ada
try {
db.exec(`
INSERT OR IGNORE INTO expense_categories (name, parent_id, description, icon, color) VALUES
('Perangkat', NULL, 'Pembelian perangkat jaringan', 'bi bi-router', '#3b82f6'),
('Utilitas', NULL, 'Biaya utilitas (listrik, internet, dll)', 'bi bi-lightning-charge', '#f59e0b'),
('Gaji & Tunjangan', NULL, 'Gaji karyawan dan tunjangan', 'bi bi-wallet2', '#10b981'),
('Maintenance', NULL, 'Biaya perawatan dan perbaikan', 'bi bi-tools', '#ef4444'),
('Operasional', NULL, 'Biaya operasional lainnya', 'bi bi-briefcase', '#8b5cf6');
`);
} catch (e) {}
// Safe migration: tambah kolom balance, area, dan is_radius ke tabel customers, collectors, dan technicians
try {
const custCols = db.prepare("PRAGMA table_info(customers)").all();
if (!custCols.find(c => c.name === 'balance')) {
db.exec("ALTER TABLE customers ADD COLUMN balance INTEGER NOT NULL DEFAULT 0");
}
if (!custCols.find(c => c.name === 'area')) {
db.exec("ALTER TABLE customers ADD COLUMN area TEXT DEFAULT ''");
}
if (!custCols.find(c => c.name === 'is_radius')) {
db.exec("ALTER TABLE customers ADD COLUMN is_radius INTEGER DEFAULT 1");
}
if (!custCols.find(c => c.name === 'customer_code')) {
db.exec("ALTER TABLE customers ADD COLUMN customer_code TEXT DEFAULT ''");
}
try { db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_customers_code ON customers(customer_code) WHERE customer_code IS NOT NULL AND customer_code <> ''"); } catch (e) {}
const colCols = db.prepare("PRAGMA table_info(collectors)").all();
if (!colCols.find(c => c.name === 'area')) {
db.exec("ALTER TABLE collectors ADD COLUMN area TEXT DEFAULT ''");
}
const techCols = db.prepare("PRAGMA table_info(technicians)").all();
if (!techCols.find(c => c.name === 'area')) {
db.exec("ALTER TABLE technicians ADD COLUMN area TEXT DEFAULT ''");
}
const pkgCols = db.prepare("PRAGMA table_info(packages)").all();
if (!pkgCols.find(c => c.name === 'speed_down_upto')) {
db.exec("ALTER TABLE packages ADD COLUMN speed_down_upto INTEGER DEFAULT 0");
}
if (!pkgCols.find(c => c.name === 'speed_up_upto')) {
db.exec("ALTER TABLE packages ADD COLUMN speed_up_upto INTEGER DEFAULT 0");
}
} catch(e) {
console.error('Failed to migrate customer columns:', e);
}
// Safe migration: sync area-area yang sudah pernah diinput ke tabel areas
try {
const hasCustArea = db.prepare("PRAGMA table_info(customers)").all().some(c => c.name === 'area');
const hasColArea = db.prepare("PRAGMA table_info(collectors)").all().some(c => c.name === 'area');
const hasTechArea = db.prepare("PRAGMA table_info(technicians)").all().some(c => c.name === 'area');
const syncQueries = [];
if (hasCustArea) syncQueries.push("SELECT area FROM customers WHERE area IS NOT NULL AND TRIM(area) != ''");
if (hasColArea) syncQueries.push("SELECT area FROM collectors WHERE area IS NOT NULL AND TRIM(area) != ''");
if (hasTechArea) syncQueries.push("SELECT area FROM technicians WHERE area IS NOT NULL AND TRIM(area) != ''");
if (syncQueries.length > 0) {
db.exec(`
INSERT OR IGNORE INTO areas (name)
SELECT DISTINCT TRIM(area) FROM (
` + syncQueries.join(' UNION ') + `
);
`);
}
} catch(e) {}
// Safe migration: public_donation_orders table & indexes
try {
db.exec(`
CREATE TABLE IF NOT EXISTS public_donation_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
donor_name TEXT,
donor_phone TEXT NOT NULL,
amount INTEGER NOT NULL DEFAULT 0,
qris_amount_unique INTEGER,
qris_unique_code INTEGER,
notes TEXT,
status TEXT DEFAULT 'pending',
paid_at DATETIME,
qris_paid_notif_id INTEGER,
activation_code TEXT DEFAULT '',
wa_sent INTEGER DEFAULT 0,
wa_sent_at DATETIME,
wa_error TEXT,
created_at DATETIME DEFAULT (NOW_LOCAL()),
updated_at DATETIME DEFAULT (NOW_LOCAL())
);
CREATE INDEX IF NOT EXISTS idx_donation_orders_status ON public_donation_orders(status);
CREATE INDEX IF NOT EXISTS idx_donation_orders_unique ON public_donation_orders(qris_amount_unique, status);
`);
const colNotifs = db.prepare("PRAGMA table_info(webhook_payment_notifs)").all();
if (colNotifs.length > 0 && !colNotifs.some(c => c.name === 'matched_donation_order_id')) {
db.exec("ALTER TABLE webhook_payment_notifs ADD COLUMN matched_donation_order_id INTEGER");
}
} catch(e) {
console.error('Failed to migrate public_donation_orders:', e);
}
// Safe migration: automation_runs (riwayat otomatisasi pengiriman tagihan)
try {
db.exec(`
CREATE TABLE IF NOT EXISTS automation_runs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT,
template_key TEXT,
isolate_day INTEGER DEFAULT 0,
mode TEXT,
interval_label TEXT,
target INTEGER DEFAULT 0,
sent INTEGER DEFAULT 0,
failed INTEGER DEFAULT 0,
status TEXT,
note TEXT,
started_at TEXT DEFAULT (NOW_LOCAL()),
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_automation_runs_started ON automation_runs(started_at);
`);
} catch(e) { console.error('Failed to migrate automation_runs:', e); }
// Safe migration: kolom IP pool & profil PPP di packages
try {
const _pc = db.prepare("PRAGMA table_info(packages)").all();
const _have = new Set(_pc.map(c => c.name));
const _add = [
['pool_name', "TEXT DEFAULT ''"],
['pool_ranges', "TEXT DEFAULT ''"],
['local_address', "TEXT DEFAULT ''"],
['only_one', 'INTEGER DEFAULT 1'],
['parent_queue', "TEXT DEFAULT ''"],
['queue_type', "TEXT DEFAULT ''"],
['isolir_profile', "TEXT DEFAULT ''"]
];
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); }
module.exports = db;
module.exports.getAppSetting = getAppSetting;
module.exports.saveAppSetting = saveAppSetting;