OPEN-ISP v0.5 (pickle-lime) — rilis open-source perdana
Aplikasi billing & manajemen ISP (fiber optic) — PWA pelanggan, portal teknisi & kolektor, admin panel lengkap, multi-vendor OLT, RADIUS, WhatsApp/Telegram, pembayaran QRIS. Kredit & lisensi (ISC): - Base kode & repo: alijayanet/billing-rtrw oleh Alijaya Net — terima kasih banyak atas base kode dan repo yang bermanfaat. - Add-on: GoWA, LibreSpeed/OpenSpeedTest, Uptime Kuma, notification-forwarder. - Dikembangkan menjadi OPEN-ISP oleh oxpoe — https://github.com/oxpoe/open-isp
This commit is contained in:
commit
eaf05f2811
280 files changed
+123975
No files matched your search
@@ -0,0 +1,449 @@
|
||||
/**
|
||||
* Service: Payroll / Gaji Karyawan
|
||||
* Mengelola pengaturan gaji, generate slip gaji, dan kalkulasi otomatis
|
||||
*/
|
||||
const db = require('../config/database');
|
||||
const { logger } = require('../config/logger');
|
||||
const attendanceSvc = require('./attendanceService');
|
||||
|
||||
const EMPLOYEE_TYPES = ['technician', 'cashier', 'collector'];
|
||||
|
||||
// ─── PAYROLL SETTINGS ────────────────────────────────────────────────────────
|
||||
|
||||
function getPayrollSetting(employeeType, employeeId) {
|
||||
return db.prepare(
|
||||
'SELECT * FROM payroll_settings WHERE employee_type = ? AND employee_id = ?'
|
||||
).get(employeeType, employeeId) || null;
|
||||
}
|
||||
|
||||
function getAllPayrollSettings() {
|
||||
return db.prepare('SELECT * FROM payroll_settings ORDER BY employee_type, employee_id').all();
|
||||
}
|
||||
|
||||
function upsertPayrollSetting(data) {
|
||||
const existing = getPayrollSetting(data.employee_type, data.employee_id);
|
||||
if (existing) {
|
||||
return db.prepare(`
|
||||
UPDATE payroll_settings SET
|
||||
base_salary = ?, transport_allowance = ?, meal_allowance = ?,
|
||||
phone_allowance = ?, other_allowance = ?, other_allowance_note = ?,
|
||||
absence_deduction_per_day = ?, bonus_per_ticket = ?,
|
||||
commission_percentage = ?, working_days_per_month = ?,
|
||||
updated_at = (NOW_LOCAL())
|
||||
WHERE employee_type = ? AND employee_id = ?
|
||||
`).run(
|
||||
parseInt(data.base_salary) || 0,
|
||||
parseInt(data.transport_allowance) || 0,
|
||||
parseInt(data.meal_allowance) || 0,
|
||||
parseInt(data.phone_allowance) || 0,
|
||||
parseInt(data.other_allowance) || 0,
|
||||
data.other_allowance_note || '',
|
||||
parseInt(data.absence_deduction_per_day) || 0,
|
||||
parseInt(data.bonus_per_ticket) || 0,
|
||||
parseFloat(data.commission_percentage) || 0,
|
||||
parseInt(data.working_days_per_month) || 26,
|
||||
data.employee_type,
|
||||
data.employee_id
|
||||
);
|
||||
} else {
|
||||
return db.prepare(`
|
||||
INSERT INTO payroll_settings (
|
||||
employee_type, employee_id,
|
||||
base_salary, transport_allowance, meal_allowance,
|
||||
phone_allowance, other_allowance, other_allowance_note,
|
||||
absence_deduction_per_day, bonus_per_ticket,
|
||||
commission_percentage, working_days_per_month
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
data.employee_type,
|
||||
parseInt(data.employee_id),
|
||||
parseInt(data.base_salary) || 0,
|
||||
parseInt(data.transport_allowance) || 0,
|
||||
parseInt(data.meal_allowance) || 0,
|
||||
parseInt(data.phone_allowance) || 0,
|
||||
parseInt(data.other_allowance) || 0,
|
||||
data.other_allowance_note || '',
|
||||
parseInt(data.absence_deduction_per_day) || 0,
|
||||
parseInt(data.bonus_per_ticket) || 0,
|
||||
parseFloat(data.commission_percentage) || 0,
|
||||
parseInt(data.working_days_per_month) || 26
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function deletePayrollSetting(employeeType, employeeId) {
|
||||
return db.prepare(
|
||||
'DELETE FROM payroll_settings WHERE employee_type = ? AND employee_id = ?'
|
||||
).run(employeeType, employeeId);
|
||||
}
|
||||
|
||||
// ─── EMPLOYEE LIST ───────────────────────────────────────────────────────────
|
||||
|
||||
function getAllEmployees() {
|
||||
const employees = [];
|
||||
|
||||
const technicians = db.prepare('SELECT id, name, phone, is_active FROM technicians WHERE is_active = 1').all();
|
||||
for (const t of technicians) {
|
||||
const setting = getPayrollSetting('technician', t.id);
|
||||
employees.push({ ...t, employee_type: 'technician', payroll: setting });
|
||||
}
|
||||
|
||||
const cashiers = db.prepare('SELECT id, name, phone, is_active FROM cashiers WHERE is_active = 1').all();
|
||||
for (const c of cashiers) {
|
||||
const setting = getPayrollSetting('cashier', c.id);
|
||||
employees.push({ ...c, employee_type: 'cashier', payroll: setting });
|
||||
}
|
||||
|
||||
const collectors = db.prepare('SELECT id, name, phone, is_active FROM collectors WHERE is_active = 1').all();
|
||||
for (const c of collectors) {
|
||||
const setting = getPayrollSetting('collector', c.id);
|
||||
employees.push({ ...c, employee_type: 'collector', payroll: setting });
|
||||
}
|
||||
|
||||
return employees;
|
||||
}
|
||||
|
||||
function getEmployeeName(employeeType, employeeId) {
|
||||
const table = employeeType === 'technician' ? 'technicians'
|
||||
: employeeType === 'cashier' ? 'cashiers'
|
||||
: employeeType === 'collector' ? 'collectors' : null;
|
||||
if (!table) return 'Unknown';
|
||||
const row = db.prepare(`SELECT name FROM ${table} WHERE id = ?`).get(employeeId);
|
||||
return row ? row.name : 'Unknown';
|
||||
}
|
||||
|
||||
function getEmployeePhone(employeeType, employeeId) {
|
||||
const table = employeeType === 'technician' ? 'technicians'
|
||||
: employeeType === 'cashier' ? 'cashiers'
|
||||
: employeeType === 'collector' ? 'collectors' : null;
|
||||
if (!table) return '';
|
||||
const row = db.prepare(`SELECT phone FROM ${table} WHERE id = ?`).get(employeeId);
|
||||
return row ? (row.phone || '') : '';
|
||||
}
|
||||
|
||||
// ─── PERFORMANCE DATA ────────────────────────────────────────────────────────
|
||||
|
||||
function getTicketsResolvedCount(technicianId, month, year) {
|
||||
const monthStr = String(month).padStart(2, '0');
|
||||
const yearStr = String(year);
|
||||
const result = db.prepare(`
|
||||
SELECT COUNT(*) as cnt FROM tickets
|
||||
WHERE technician_id = ? AND status = 'resolved'
|
||||
AND strftime('%Y', datetime(updated_at, 'localtime')) = ?
|
||||
AND strftime('%m', datetime(updated_at, 'localtime')) = ?
|
||||
`).get(technicianId, yearStr, monthStr);
|
||||
return result ? result.cnt : 0;
|
||||
}
|
||||
|
||||
function getCollectionAmount(collectorId, month, year) {
|
||||
const monthStr = String(month).padStart(2, '0');
|
||||
const yearStr = String(year);
|
||||
const result = db.prepare(`
|
||||
SELECT COALESCE(SUM(amount), 0) as total FROM collector_payment_requests
|
||||
WHERE collector_id = ? AND status = 'approved'
|
||||
AND strftime('%Y', datetime(created_at, 'localtime')) = ?
|
||||
AND strftime('%m', datetime(created_at, 'localtime')) = ?
|
||||
`).get(collectorId, yearStr, monthStr);
|
||||
return result ? result.total : 0;
|
||||
}
|
||||
|
||||
function getAttendanceData(employeeType, employeeId, month, year) {
|
||||
const monthStr = String(month).padStart(2, '0');
|
||||
const yearStr = String(year);
|
||||
|
||||
const summary = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total_days,
|
||||
SUM(CASE WHEN status = 'checked_out' THEN 1 ELSE 0 END) as completed_days,
|
||||
SUM(work_duration_minutes) as total_minutes,
|
||||
SUM(CASE WHEN time(datetime(check_in_time, 'localtime')) > '08:30:00' THEN 1 ELSE 0 END) as late_days,
|
||||
SUM(CASE WHEN work_duration_minutes > 480 THEN work_duration_minutes - 480 ELSE 0 END) as overtime_minutes
|
||||
FROM attendance
|
||||
WHERE employee_type = ? AND employee_id = ?
|
||||
AND strftime('%Y', datetime(check_in_time, 'localtime')) = ?
|
||||
AND strftime('%m', datetime(check_in_time, 'localtime')) = ?
|
||||
`).get(employeeType, employeeId, yearStr, monthStr);
|
||||
|
||||
return {
|
||||
workingDays: summary ? (summary.total_days || 0) : 0,
|
||||
completedDays: summary ? (summary.completed_days || 0) : 0,
|
||||
totalMinutes: summary ? (summary.total_minutes || 0) : 0,
|
||||
lateDays: summary ? (summary.late_days || 0) : 0,
|
||||
overtimeMinutes: summary ? (summary.overtime_minutes || 0) : 0
|
||||
};
|
||||
}
|
||||
|
||||
// ─── PAYROLL SLIP GENERATION ─────────────────────────────────────────────────
|
||||
|
||||
function generateSlip(employeeType, employeeId, month, year) {
|
||||
const setting = getPayrollSetting(employeeType, employeeId);
|
||||
if (!setting) {
|
||||
throw new Error(`Pengaturan gaji belum diset untuk ${employeeType} ID ${employeeId}`);
|
||||
}
|
||||
|
||||
// Cek apakah slip sudah ada
|
||||
const existing = db.prepare(`
|
||||
SELECT id FROM payroll_slips
|
||||
WHERE employee_type = ? AND employee_id = ? AND period_month = ? AND period_year = ?
|
||||
`).get(employeeType, employeeId, month, year);
|
||||
|
||||
if (existing) {
|
||||
throw new Error(`Slip gaji sudah ada untuk periode ${month}/${year}. Hapus dulu jika ingin generate ulang.`);
|
||||
}
|
||||
|
||||
const employeeName = getEmployeeName(employeeType, employeeId);
|
||||
const attendance = getAttendanceData(employeeType, employeeId, month, year);
|
||||
|
||||
// Hitung hari absen
|
||||
const absentDays = Math.max(0, setting.working_days_per_month - attendance.workingDays);
|
||||
|
||||
// Hitung bonus performa
|
||||
let ticketsResolved = 0;
|
||||
let ticketBonus = 0;
|
||||
if (employeeType === 'technician' && setting.bonus_per_ticket > 0) {
|
||||
ticketsResolved = getTicketsResolvedCount(employeeId, month, year);
|
||||
ticketBonus = ticketsResolved * setting.bonus_per_ticket;
|
||||
}
|
||||
|
||||
let collectionAmount = 0;
|
||||
let collectionCommission = 0;
|
||||
if (employeeType === 'collector' && setting.commission_percentage > 0) {
|
||||
collectionAmount = getCollectionAmount(employeeId, month, year);
|
||||
collectionCommission = Math.round(collectionAmount * (setting.commission_percentage / 100));
|
||||
}
|
||||
|
||||
// Hitung overtime bonus (Rp 15.000 per jam lembur)
|
||||
const overtimeHours = Math.round((attendance.overtimeMinutes / 60) * 10) / 10;
|
||||
const overtimeBonus = Math.round(overtimeHours * 15000);
|
||||
|
||||
// Hitung potongan
|
||||
const absenceDeduction = absentDays * setting.absence_deduction_per_day;
|
||||
const lateDeduction = attendance.lateDays * Math.round(setting.absence_deduction_per_day * 0.25); // 25% potongan per hari terlambat
|
||||
|
||||
// Total pendapatan
|
||||
const grossSalary = setting.base_salary
|
||||
+ setting.transport_allowance
|
||||
+ setting.meal_allowance
|
||||
+ setting.phone_allowance
|
||||
+ setting.other_allowance
|
||||
+ ticketBonus
|
||||
+ collectionCommission
|
||||
+ overtimeBonus;
|
||||
|
||||
// Total potongan
|
||||
const totalDeductions = absenceDeduction + lateDeduction;
|
||||
|
||||
// Gaji bersih
|
||||
const netSalary = Math.max(0, grossSalary - totalDeductions);
|
||||
|
||||
const result = db.prepare(`
|
||||
INSERT INTO payroll_slips (
|
||||
employee_type, employee_id, employee_name, period_month, period_year,
|
||||
base_salary, transport_allowance, meal_allowance, phone_allowance,
|
||||
other_allowance, other_allowance_note,
|
||||
working_days, absent_days, late_days, overtime_hours,
|
||||
total_tickets_resolved, total_collection_amount,
|
||||
ticket_bonus, collection_commission, overtime_bonus,
|
||||
absence_deduction, late_deduction, other_deduction, other_deduction_note,
|
||||
gross_salary, total_deductions, net_salary, status
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'draft')
|
||||
`).run(
|
||||
employeeType, employeeId, employeeName, month, year,
|
||||
setting.base_salary, setting.transport_allowance, setting.meal_allowance,
|
||||
setting.phone_allowance, setting.other_allowance, setting.other_allowance_note || '',
|
||||
attendance.workingDays, absentDays, attendance.lateDays, overtimeHours,
|
||||
ticketsResolved, collectionAmount,
|
||||
ticketBonus, collectionCommission, overtimeBonus,
|
||||
absenceDeduction, lateDeduction, 0, '',
|
||||
grossSalary, totalDeductions, netSalary
|
||||
);
|
||||
|
||||
return result.lastInsertRowid;
|
||||
}
|
||||
|
||||
function generateAllSlips(month, year) {
|
||||
const employees = getAllEmployees();
|
||||
let generated = 0;
|
||||
let skipped = 0;
|
||||
const errors = [];
|
||||
|
||||
for (const emp of employees) {
|
||||
if (!emp.payroll) {
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
generateSlip(emp.employee_type, emp.id, month, year);
|
||||
generated++;
|
||||
} catch (e) {
|
||||
if (e.message.includes('Slip gaji sudah ada')) {
|
||||
skipped++;
|
||||
} else {
|
||||
errors.push(`${emp.name}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { generated, skipped, errors };
|
||||
}
|
||||
|
||||
// ─── PAYROLL SLIP CRUD ───────────────────────────────────────────────────────
|
||||
|
||||
function getSlipById(id) {
|
||||
return db.prepare('SELECT * FROM payroll_slips WHERE id = ?').get(id);
|
||||
}
|
||||
|
||||
function getSlipsByPeriod(month, year) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM payroll_slips
|
||||
WHERE period_month = ? AND period_year = ?
|
||||
ORDER BY employee_type, employee_name
|
||||
`).all(month, year);
|
||||
}
|
||||
|
||||
function getSlipsByEmployee(employeeType, employeeId) {
|
||||
return db.prepare(`
|
||||
SELECT * FROM payroll_slips
|
||||
WHERE employee_type = ? AND employee_id = ?
|
||||
ORDER BY period_year DESC, period_month DESC
|
||||
`).all(employeeType, employeeId);
|
||||
}
|
||||
|
||||
function updateSlipDeductions(id, otherDeduction, otherDeductionNote) {
|
||||
const slip = getSlipById(id);
|
||||
if (!slip) throw new Error('Slip tidak ditemukan');
|
||||
if (slip.status !== 'draft') throw new Error('Hanya slip draft yang bisa diedit');
|
||||
|
||||
const od = parseInt(otherDeduction) || 0;
|
||||
const newTotalDeductions = slip.absence_deduction + slip.late_deduction + od;
|
||||
const newNetSalary = Math.max(0, slip.gross_salary - newTotalDeductions);
|
||||
|
||||
return db.prepare(`
|
||||
UPDATE payroll_slips SET
|
||||
other_deduction = ?, other_deduction_note = ?,
|
||||
total_deductions = ?, net_salary = ?
|
||||
WHERE id = ?
|
||||
`).run(od, otherDeductionNote || '', newTotalDeductions, newNetSalary, id);
|
||||
}
|
||||
|
||||
function approveSlip(id) {
|
||||
const slip = getSlipById(id);
|
||||
if (!slip) throw new Error('Slip tidak ditemukan');
|
||||
if (slip.status !== 'draft') throw new Error('Hanya slip draft yang bisa di-approve');
|
||||
|
||||
return db.prepare(`
|
||||
UPDATE payroll_slips SET status = 'approved', approved_at = (NOW_LOCAL()) WHERE id = ?
|
||||
`).run(id);
|
||||
}
|
||||
|
||||
function markSlipPaid(id) {
|
||||
const slip = getSlipById(id);
|
||||
if (!slip) throw new Error('Slip tidak ditemukan');
|
||||
if (slip.status !== 'approved') throw new Error('Hanya slip yang sudah approved yang bisa ditandai paid');
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE payroll_slips SET status = 'paid', paid_at = (NOW_LOCAL()) WHERE id = ?
|
||||
`).run(id);
|
||||
|
||||
// Auto Insert ke Tabel Pengeluaran (Expenses)
|
||||
try {
|
||||
db.prepare(`
|
||||
INSERT INTO expenses (date, category, amount, description, payment_method, recorded_by_name)
|
||||
VALUES (date('now', 'localtime'), 'Gaji & Tunjangan', ?, ?, 'transfer', 'Sistem (Payroll)')
|
||||
`).run(slip.net_salary, `Gaji ${slip.employee_name} (Periode ${slip.period_month}/${slip.period_year})`);
|
||||
} catch (e) {
|
||||
logger.error('Gagal mencatat expense dari slip gaji: ' + e.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function bulkApprove(month, year) {
|
||||
return db.prepare(`
|
||||
UPDATE payroll_slips SET status = 'approved', approved_at = (NOW_LOCAL())
|
||||
WHERE period_month = ? AND period_year = ? AND status = 'draft'
|
||||
`).run(month, year);
|
||||
}
|
||||
|
||||
function bulkMarkPaid(month, year) {
|
||||
const slipsToPay = db.prepare(`SELECT * FROM payroll_slips WHERE period_month = ? AND period_year = ? AND status = 'approved'`).all(month, year);
|
||||
|
||||
const result = db.prepare(`
|
||||
UPDATE payroll_slips SET status = 'paid', paid_at = (NOW_LOCAL())
|
||||
WHERE period_month = ? AND period_year = ? AND status = 'approved'
|
||||
`).run(month, year);
|
||||
|
||||
// Auto Insert ke Tabel Pengeluaran (Expenses) untuk semua slip
|
||||
try {
|
||||
const stmt = db.prepare(`
|
||||
INSERT INTO expenses (date, category, amount, description, payment_method, recorded_by_name)
|
||||
VALUES (date('now', 'localtime'), 'Gaji & Tunjangan', ?, ?, 'transfer', 'Sistem (Payroll)')
|
||||
`);
|
||||
|
||||
for (const slip of slipsToPay) {
|
||||
stmt.run(slip.net_salary, `Gaji ${slip.employee_name} (Periode ${slip.period_month}/${slip.period_year})`);
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('Gagal mencatat expense bulk slip gaji: ' + e.message);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function deleteSlip(id) {
|
||||
const slip = getSlipById(id);
|
||||
if (!slip) throw new Error('Slip tidak ditemukan');
|
||||
if (slip.status === 'paid') throw new Error('Slip yang sudah paid tidak bisa dihapus');
|
||||
return db.prepare('DELETE FROM payroll_slips WHERE id = ?').run(id);
|
||||
}
|
||||
|
||||
function deleteSlipsByPeriod(month, year) {
|
||||
return db.prepare(`
|
||||
DELETE FROM payroll_slips
|
||||
WHERE period_month = ? AND period_year = ? AND status = 'draft'
|
||||
`).run(month, year);
|
||||
}
|
||||
|
||||
// ─── STATISTICS ──────────────────────────────────────────────────────────────
|
||||
|
||||
function getPayrollSummary(month, year) {
|
||||
const result = db.prepare(`
|
||||
SELECT
|
||||
COUNT(*) as total_slips,
|
||||
SUM(CASE WHEN status = 'draft' THEN 1 ELSE 0 END) as draft_count,
|
||||
SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved_count,
|
||||
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) as paid_count,
|
||||
COALESCE(SUM(gross_salary), 0) as total_gross,
|
||||
COALESCE(SUM(total_deductions), 0) as total_deductions,
|
||||
COALESCE(SUM(net_salary), 0) as total_net
|
||||
FROM payroll_slips
|
||||
WHERE period_month = ? AND period_year = ?
|
||||
`).get(month, year);
|
||||
|
||||
return result || {
|
||||
total_slips: 0, draft_count: 0, approved_count: 0, paid_count: 0,
|
||||
total_gross: 0, total_deductions: 0, total_net: 0
|
||||
};
|
||||
}
|
||||
|
||||
function getAvailablePeriods() {
|
||||
return db.prepare(`
|
||||
SELECT DISTINCT period_month, period_year
|
||||
FROM payroll_slips
|
||||
ORDER BY period_year DESC, period_month DESC
|
||||
LIMIT 24
|
||||
`).all();
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EMPLOYEE_TYPES,
|
||||
getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting,
|
||||
getAllEmployees, getEmployeeName, getEmployeePhone,
|
||||
getTicketsResolvedCount, getCollectionAmount, getAttendanceData,
|
||||
generateSlip, generateAllSlips,
|
||||
getSlipById, getSlipsByPeriod, getSlipsByEmployee,
|
||||
updateSlipDeductions, approveSlip, markSlipPaid,
|
||||
bulkApprove, bulkMarkPaid,
|
||||
deleteSlip, deleteSlipsByPeriod,
|
||||
getPayrollSummary, getAvailablePeriods
|
||||
};
|
||||
Reference in new issue
Block a user