/** * 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;