diff --git a/config/database.js b/config/database.js index 8e7baed..53dd992 100644 --- a/config/database.js +++ b/config/database.js @@ -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; diff --git a/routes/adminPortal.js b/routes/adminPortal.js index e9b42cf..d96c41a 100644 --- a/routes/adminPortal.js +++ b/routes/adminPortal.js @@ -5096,7 +5096,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 +5106,8 @@ router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventor msg: flashMsg(req), items, categories, - logs + logs, + stats }); }); @@ -5159,6 +5161,54 @@ 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('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => { const filters = { action: req.query.action || null, @@ -7790,6 +7840,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); diff --git a/services/inventoryService.js b/services/inventoryService.js index 0dedcda..8927acc 100644 --- a/services/inventoryService.js +++ b/services/inventoryService.js @@ -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 }; diff --git a/services/payrollService.js b/services/payrollService.js index 99ed7b8..45fb138 100644 --- a/services/payrollService.js +++ b/services/payrollService.js @@ -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 diff --git a/views/admin/inventory.ejs b/views/admin/inventory.ejs index 9d926c2..770c01c 100644 --- a/views/admin/inventory.ejs +++ b/views/admin/inventory.ejs @@ -93,6 +93,9 @@
+ + +
@@ -250,6 +253,76 @@ + + + + + + + + + + + + +