update HR dan Payroll

This commit is contained in:
Wian Drs
2026-09-14 11:14:25 +07:00
parent ec94783db4
commit 63993ee76e
50 changed files with 2432 additions and 1473 deletions
@@ -77,7 +77,9 @@ class ApprovalService
'stock_document'=>array('inventoryprofessional?focus='.$id,'Buka Dokumen Persediaan'),
'cash_transaction'=>array('cashbank?focus='.$id,'Buka Transaksi Kas/Bank'),
'payroll_period'=>array('hrpayroll?focus='.$id,'Buka Payroll'),
'manual_attendance'=>array('attendancemonitoring?focus='.$id,'Buka Absensi Manual'),
'attendance_correction'=>array('attendancemonitoring?focus='.$id,'Buka Koreksi Absensi'),
'leave_request'=>array('leaverequests?focus='.$id,'Buka Pengajuan Cuti'),
'budget'=>array('budgets?focus='.$id,'Buka Budget')
);
if(isset($map[$type]))return $map[$type];
+6 -2
View File
@@ -2,9 +2,11 @@
defined('BASEPATH') OR exit('No direct script access allowed');
class NavigationService
{
private $CI;private $pendingBadge=null;private $inventoryDocumentBadge=null;private $technicianBadge=null;private $payableBadge=null;private $payableBadgeClass='';private $returnBadge=null;private $returnBadgeClass='';private $budgetBadge=null;private $salesBadges=null;public function __construct(){$this->CI=&get_instance();}
private $CI;private $pendingBadge=null;private $inventoryDocumentBadge=null;private $technicianBadge=null;private $payableBadge=null;private $payableBadgeClass='';private $returnBadge=null;private $returnBadgeClass='';private $budgetBadge=null;private $leaveBadge=null;private $attendanceBadge=null;private $salesBadges=null;public function __construct(){$this->CI=&get_instance();}
private function allowed($feature){if(is_master_admin_user())return true;foreach((array)$feature as$name)if(check_permission($name,'can_view'))return true;return false;}
public function items(){
$employeeUrl=$this->allowed(array('hr_employees','employees'))?'employees':'organizationmaster';
$attendanceUrl=$this->allowed(array('hr_attendance','employees'))?'attendancemonitoring':($this->allowed(array('hr_schedules','employees'))?'generateschedule':'leaverequests');
$items=array(
array('key'=>'dashboard','label'=>'Dashboard','icon'=>'bi-speedometer2','url'=>'','feature'=>'dashboard'),
array('key'=>'approvals','label'=>'Approval','icon'=>'bi-inbox','url'=>'approvals','feature'=>'approvals','badge'=>$this->approvalBadge()),
@@ -15,12 +17,14 @@ class NavigationService
array('key'=>'accounting','label'=>'Accounting','icon'=>'bi-journal-text','feature'=>'jurnal','children'=>array(array('key'=>'jurnal','label'=>'Jurnal Umum','url'=>'jurnal'),array('key'=>'accounts','label'=>'Daftar Akun','url'=>'accounts'),array('key'=>'basejurnal','label'=>'Base Jurnal','url'=>'basejurnal'),array('key'=>'accounting_periods','label'=>'Periode Accounting','url'=>'accountingperiods'))),
array('key'=>'professional_reports','label'=>'Laporan Profesional','icon'=>'bi-bar-chart','url'=>'professionalreports','feature'=>'reports'),
array('key'=>'budgets','label'=>'Budget','icon'=>'bi-bullseye','url'=>'budgets','feature'=>'budgets','badge'=>$this->budgetApprovalBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengingat pembayaran budget yang perlu diperhatikan'),
array('key'=>'hr','label'=>'HR & Payroll','icon'=>'bi-people','feature'=>'employees','children'=>array(array('key'=>'organization_master','label'=>'Departemen & Posisi','url'=>'organizationmaster'),array('key'=>'employees','label'=>'Data Karyawan','url'=>'employees'),array('key'=>'generate_schedule','label'=>'Jadwal Kerja','url'=>'generateschedule'),array('key'=>'attendance_monitoring','label'=>'Monitoring Absensi','url'=>'attendancemonitoring'),array('key'=>'attendance_monthly','label'=>'Absensi Bulanan','url'=>'attendancemonthly'),array('key'=>'shift','label'=>'Shift','url'=>'shifts'),array('key'=>'holidays','label'=>'Hari Libur','url'=>'holidays'),array('key'=>'leave_requests','label'=>'Cuti & Izin','url'=>'leaverequests'),array('key'=>'hr_payroll','label'=>'Payroll Control','url'=>'hrpayroll'))),
array('key'=>'hr','label'=>'HR & Payroll','icon'=>'bi-people','feature'=>array('hr_organization','hr_employees','hr_schedules','hr_attendance','hr_leave','payroll','employees'),'badge'=>$this->leaveApprovalBadge()+$this->manualAttendanceBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengajuan HR menunggu tindakan','children'=>array(array('key'=>'employees','label'=>'Karyawan','url'=>$employeeUrl,'feature'=>array('hr_employees','hr_organization','employees'),'active_keys'=>array('employees','organization_master')),array('key'=>'attendance_monitoring','label'=>'Absen & Kehadiran','url'=>$attendanceUrl,'feature'=>array('hr_schedules','hr_attendance','hr_leave','employees'),'active_keys'=>array('generate_schedule','attendance_monitoring','attendance_monthly','shift','holidays','leave_requests'),'badge'=>$this->leaveApprovalBadge()+$this->manualAttendanceBadge(),'badge_class'=>'nav-badge-warning','badge_title'=>'Pengajuan absensi atau cuti menunggu tindakan'),array('key'=>'hr_payroll','label'=>'Payroll','url'=>'hrpayroll','feature'=>array('payroll','employees')))),
array('key'=>'companies','label'=>'Company Setting','icon'=>'bi-buildings','url'=>'companies','feature'=>'companies','admin'=>true),
array('key'=>'settings','label'=>'Pengaturan','icon'=>'bi-gear','feature'=>'setting','admin'=>true,'children'=>array(array('key'=>'users','label'=>'Pengguna','url'=>'users'),array('key'=>'roles','label'=>'Role & Permission','url'=>'roles')))
);$out=array();foreach($items as$item){if(!empty($item['admin'])&&!is_master_admin_user())continue;if(!$this->allowed($item['feature']))continue;if(!empty($item['children'])){$children=array();foreach($item['children']as$c){if(!empty($c['admin'])&&!is_master_admin_user())continue;if(!empty($c['feature'])&&!$this->allowed($c['feature']))continue;$children[]=$c;}$item['children']=$children;if(!$children)continue;}$out[]=$item;}return$out;
}
private function approvalBadge(){if(!$this->CI->db->table_exists('approval_requests'))return 0;return(int)$this->CI->db->where('status','pending')->count_all_results('approval_requests');}
private function leaveApprovalBadge(){if($this->leaveBadge!==null)return$this->leaveBadge;if(!$this->CI->db->table_exists('k_leave_requests')||!$this->CI->db->field_exists('workflow_status','k_leave_requests'))return$this->leaveBadge=0;$this->CI->db->where('workflow_status','submitted');if($this->CI->db->field_exists('company_id','k_leave_requests'))$this->CI->db->where('company_id',(int)$this->CI->session->userdata('company_id'));return$this->leaveBadge=(int)$this->CI->db->count_all_results('k_leave_requests');}
private function manualAttendanceBadge(){if($this->attendanceBadge!==null)return$this->attendanceBadge;if(!$this->CI->db->table_exists('manual_attendance_requests'))return$this->attendanceBadge=0;$this->CI->db->where('status','submitted')->where('company_id',(int)$this->CI->session->userdata('company_id'));return$this->attendanceBadge=(int)$this->CI->db->count_all_results('manual_attendance_requests');}
private function salesBadge($name){if($this->salesBadges===null){$c=(int)$this->CI->session->userdata('company_id');$this->salesBadges=array('draft'=>0,'approval'=>0,'delivery'=>0,'receivable'=>0,'overdue'=>0,'return'=>0);if($this->CI->db->table_exists('invoices')){$where=$this->CI->db->field_exists('company_id','invoices')?' AND company_id='.(int)$c:'';$r=$this->CI->db->query("SELECT SUM(workflow_status='draft') draft_count,SUM(workflow_status='submitted') approval_count,SUM(workflow_status='posted' AND sisa_piutang>0) receivable_count,SUM(workflow_status='posted' AND sisa_piutang>0 AND jatuh_tempo<CURDATE()) overdue_count FROM invoices WHERE deleted_at IS NULL".$where)->row();if($r){$this->salesBadges['draft']=(int)$r->draft_count;$this->salesBadges['approval']=(int)$r->approval_count;$this->salesBadges['receivable']=(int)$r->receivable_count;$this->salesBadges['overdue']=(int)$r->overdue_count;}}if($this->CI->db->table_exists('sales_deliveries')){$this->CI->db->where('status','draft');if($this->CI->db->field_exists('company_id','sales_deliveries'))$this->CI->db->where('company_id',$c);$this->salesBadges['delivery']=(int)$this->CI->db->count_all_results('sales_deliveries');}if($this->CI->db->table_exists('sales_returns')){$this->CI->db->where_in('status',array('draft','submitted','approved','received'));if($this->CI->db->field_exists('company_id','sales_returns'))$this->CI->db->where('company_id',$c);$this->salesBadges['return']=(int)$this->CI->db->count_all_results('sales_returns');}}if($name==='all')return$this->salesBadges['draft']+$this->salesBadges['approval']+$this->salesBadges['delivery']+$this->salesBadges['receivable']+$this->salesBadges['return'];return$this->salesBadges[$name]??0;}
private function budgetApprovalBadge(){if($this->budgetBadge!==null)return$this->budgetBadge;if(!$this->allowed('budgets'))return$this->budgetBadge=0;$company=(int)$this->CI->session->userdata('company_id');$total=0;if($this->CI->db->table_exists('budgets')&&(is_master_admin_user()||check_permission('budgets','can_approve'))){$this->CI->db->where('status','submitted');if($this->CI->db->field_exists('company_id','budgets'))$this->CI->db->where('company_id',$company);$total+=(int)$this->CI->db->count_all_results('budgets');}if($this->CI->db->table_exists('account_budget_entries')&&$this->CI->db->table_exists('account_budget_reminder_completions')){$period=date('Y-m');$row=$this->CI->db->query("SELECT COUNT(*) total FROM account_budget_entries e WHERE e.company_id=? AND e.is_active=1 AND e.reminder_enabled=1 AND ((e.entry_type='recurring' AND e.period<=?) OR (e.entry_type='override' AND e.period=?)) AND LEAST(e.reminder_day,DAY(LAST_DAY(CURDATE())))<=LEAST(DAY(LAST_DAY(CURDATE())),DAY(CURDATE())+7) AND NOT EXISTS(SELECT 1 FROM account_budget_reminder_completions c WHERE c.company_id=e.company_id AND c.budget_entry_id=e.id AND c.period=?)",array($company,$period,$period,$period))->row();$total+=(int)($row?$row->total:0);}return$this->budgetBadge=$total;}
private function pendingItemBadge(){if($this->pendingBadge!==null)return$this->pendingBadge;if(!$this->CI->db->table_exists('items'))return$this->pendingBadge=0;$company=(int)$this->CI->session->userdata('company_id');if($this->CI->db->table_exists('item_barcodes'))return$this->pendingBadge=(int)$this->CI->db->query("SELECT COUNT(DISTINCT i.id) total FROM items i LEFT JOIN item_barcodes ib ON ib.item_id=i.id AND ib.status='pending' WHERE i.company_id=? AND EXISTS(SELECT 1 FROM goods_receipt_lines grl WHERE grl.item_id=i.id) AND(i.status='draft' OR ib.id IS NOT NULL)",array($company))->row()->total;return$this->pendingBadge=(int)$this->CI->db->where(array('status'=>'draft','company_id'=>$company))->count_all_results('items');}
+117 -6
View File
@@ -1,11 +1,122 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
require_once APPPATH.'exceptions/BusinessException.php';
class PayrollService
{
private $CI;public function __construct(){$this->CI=&get_instance();$this->CI->load->library(array('FormulaService','PostingService','FiscalPeriodService','AccountMappingService'));}
private function period($id){$p=$this->CI->db->query('SELECT * FROM k_payroll_periods WHERE id=? FOR UPDATE',array($id))->row();if(!$p)throw new BusinessException('Periode payroll tidak ditemukan.');return$p;}
public function calculate($month,$uid){$start=$month.'-01';$end=date('Y-m-t',strtotime($start));$db=$this->CI->db;$db->trans_begin();try{$locked=$db->get_where('attendance_period_locks',array('period'=>$month,'status'=>'locked'))->row();if(!$locked)throw new BusinessException('Absensi bulan '.$month.' harus dikunci sebelum payroll dihitung.');$p=$db->get_where('k_payroll_periods',array('start_date'=>$start,'end_date'=>$end))->row();if($p&&$p->workflow_status!=='draft')throw new BusinessException('Periode payroll bukan draft.');if(!$p){$db->insert('k_payroll_periods',array('period_name'=>date('F Y',strtotime($start)),'start_date'=>$start,'end_date'=>$end,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s')));$pid=$db->insert_id();}else$pid=$p->id;$components=$db->where('is_active',1)->get('payroll_components')->result();foreach($db->where('is_active',1)->get('k_employees')->result()as$e){$att=$db->query("SELECT COUNT(*) work_days,SUM(attendance_status IN('present','late')) present_days,SUM(attendance_status='alpha') unpaid_days,COALESCE(SUM(overtime_hours),0) overtime_hours FROM k_attendances WHERE employee_id=? AND attendance_date BETWEEN ? AND ?",array($e->id,$start,$end))->row();$vars=array('basic_salary'=>(float)$e->basic_salary,'calendar_days'=>(int)date('t',strtotime($start)),'work_days'=>(float)$att->work_days,'present_days'=>(float)$att->present_days,'unpaid_days'=>(float)$att->unpaid_days,'overtime_hours'=>(float)$att->overtime_hours,'overtime_rate'=>(float)(app_env('PAYROLL_OVERTIME_RATE',25000)?:25000),'daily_rate'=>(float)$e->basic_salary/max(1,(int)date('t',strtotime($start))));$pay=$db->get_where('k_payrolls',array('payroll_period_id'=>$pid,'employee_id'=>$e->id))->row();$data=array('payroll_period_id'=>$pid,'employee_id'=>$e->id,'basic_salary'=>$e->basic_salary,'total_work_days'=>$att->work_days,'total_present'=>$att->present_days,'total_alpha'=>$att->unpaid_days,'total_overtime_hours'=>$att->overtime_hours);if($pay){$db->where('id',$pay->id)->update('k_payrolls',$data);$payid=$pay->id;$db->where('payroll_id',$payid)->delete('payroll_calculation_lines');}else{$db->insert('k_payrolls',$data);$payid=$db->insert_id();}$earn=$deduct=$employer=0;foreach($components as$c){$override=$db->where('employee_id',$e->id)->where('component_id',$c->id)->where('is_active',1)->where('effective_from <=',$end)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$start)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();$amount=$override&&$override->amount_override!==null?(float)$override->amount_override:($c->fixed_amount!==null?(float)$c->fixed_amount:$this->CI->formulaservice->calculate($c->formula_expression,$vars));if($c->code==='BASIC'&&$c->prorated&&$e->join_date>$start&&$e->join_date<=$end)$amount=round($amount*(date('t',strtotime($start))-date('d',strtotime($e->join_date))+1)/date('t',strtotime($start)),2);if(abs($amount)<.01)continue;$db->insert('payroll_calculation_lines',array('payroll_id'=>$payid,'component_id'=>$c->id,'component_code'=>$c->code,'component_name'=>$c->name,'component_type'=>$c->component_type,'base_amount'=>$e->basic_salary,'amount'=>$amount,'formula_snapshot'=>$c->formula_expression,'expense_account_id'=>$c->expense_account_id,'liability_account_id'=>$c->liability_account_id));if($c->component_type==='earning')$earn+=$amount;elseif($c->component_type==='deduction')$deduct+=$amount;else$employer+=$amount;}$net=$earn-$deduct;$snap=json_encode(array('variables'=>$vars,'gross'=>$earn,'deductions'=>$deduct,'net'=>$net));$db->where('id',$payid)->update('k_payrolls',array('gross_salary'=>$earn,'total_deductions'=>$deduct,'total_salary'=>$net,'calculation_snapshot'=>$snap,'version'=>isset($pay->version)?$pay->version+1:1));}$hash=hash('sha256',json_encode($db->select('p.id,p.total_salary,p.version')->from('k_payrolls p')->where('payroll_period_id',$pid)->order_by('p.id')->get()->result_array()));$db->where('id',$pid)->update('k_payroll_periods',array('calculation_hash'=>$hash));$db->trans_commit();return$pid;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
public function workflow($id,$action,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);$map=array('review'=>array('draft','reviewed'), 'approve'=>array('reviewed','approved'), 'finalize'=>array('approved','final'));if(!isset($map[$action])||$p->workflow_status!==$map[$action][0])throw new BusinessException('Transisi payroll tidak valid.');if($action!=='review'&&(int)($action==='approve'?$p->reviewed_by:$p->approved_by)===(int)$uid)throw new BusinessException('Reviewer/approver berikutnya harus user berbeda.');$up=array('workflow_status'=>$map[$action][1],$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by')=>$uid,$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at')=>date('Y-m-d H:i:s'));$db->where('id',$id)->update('k_payroll_periods',$up);$db->trans_commit();}catch(Throwable$e){$db->trans_rollback();throw$e;}}
public function post($id,$date,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='final'||$p->journal_id)throw new BusinessException('Payroll belum final atau sudah diposting.');$this->CI->fiscalperiodservice->assertOpen($date);$gross=(float)$db->select('COALESCE(SUM(gross_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$ded=(float)$db->select('COALESCE(SUM(total_deductions),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$net=$gross-$ded;$lines=array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_expense'),'debit'=>$gross,'kredit'=>0));if($ded>0)$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_tax_payable'),'debit'=>0,'kredit'=>$ded);$lines[]=array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>0,'kredit'=>$net);$jid=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.str_replace('-','',$p->start_date),'keterangan'=>'Payroll register '.$p->period_name.'; karyawan '.$db->where('payroll_period_id',$id)->count_all_results('k_payrolls').'; bruto Rp '.number_format($gross,2,'.','').' neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),$lines,false,true);$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$jid));$db->trans_commit();return$jid;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
public function pay($id,$date,$cashAccountId,$uid){$db=$this->CI->db;$db->trans_begin();try{$p=$this->period($id);if($p->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($db->get_where('payroll_payments',array('payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cash=$db->query('SELECT * FROM cash_accounts WHERE id=? FOR UPDATE',array($cashAccountId))->row();if(!$cash)throw new BusinessException('Rekening pembayaran tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0)t',false)->get_where('k_payrolls',array('payroll_period_id'=>$id))->row()->t;$no='PRP-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$no,'keterangan'=>'Pembayaran payroll '.$p->period_name.' melalui '.$cash->name.'; total neto Rp '.number_format($net,2,'.',''),'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('payroll_period_id'=>$id,'payment_no'=>$no,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=$db->insert_id();$db->where('id',$id)->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId));$db->trans_commit();return$journal;}catch(Throwable$e){$db->trans_rollback();throw$e;}}
private $CI;
public function __construct()
{
$this->CI =& get_instance();
$this->CI->load->library(array('FormulaService','PostingService','FiscalPeriodService','AccountMappingService','AuditService'));
}
private function period($id,$companyId,$lock=true)
{
$period=$this->CI->db->query('SELECT * FROM k_payroll_periods WHERE id=? AND company_id=?'.($lock?' FOR UPDATE':''),array((int)$id,(int)$companyId))->row();
if(!$period)throw new BusinessException('Periode payroll tidak ditemukan pada perusahaan aktif.');
return $period;
}
public function calculate($periodId,$uid,$canManageAll,$companyId)
{
$db=$this->CI->db;$db->trans_begin();
try{
$period=$this->period($periodId,$companyId);
if($period->workflow_status!=='draft')throw new BusinessException('Hanya payroll Draft yang dapat dihitung ulang.');
$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;
if(!$db->get_where('payroll_attendance_locks',array('company_id'=>$companyId,'payroll_period_id'=>$period->id,'status'=>'locked'))->row())throw new BusinessException('Kehadiran periode ini harus dikunci sebelum payroll dihitung.');
$employees=$db->where(array('company_id'=>$companyId,'is_active'=>1))->order_by('full_name')->get('k_employees')->result();
if(!$canManageAll)$employees=array_values(array_filter($employees,function($employee){return empty($employee->salary_restricted);}));
if(!$employees)throw new BusinessException('Tidak ada karyawan yang dapat dihitung dengan permission Anda.');
foreach($employees as$employee)$this->calculateEmployee($period,$employee,$start,$end,$companyId,$uid);
$hash=hash('sha256',json_encode($db->select('id,total_salary,version')->where(array('payroll_period_id'=>$period->id,'company_id'=>$companyId))->order_by('id')->get('k_payrolls')->result_array()));
$db->where(array('id'=>$period->id,'company_id'=>$companyId))->update('k_payroll_periods',array('calculation_hash'=>$hash,'calculated_by'=>$uid,'calculated_at'=>date('Y-m-d H:i:s'),'calculation_version'=>(int)$period->calculation_version+1,'generated_at'=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s')));
$this->CI->auditservice->record('payroll','payroll_period',$period->id,'calculate',null,array('start'=>$start,'end'=>$end,'employees'=>count($employees),'hash'=>$hash),$uid);
if($db->trans_status()===false)throw new RuntimeException('Perhitungan payroll gagal disimpan.');$db->trans_commit();return(int)$period->id;
}catch(Throwable$e){$db->trans_rollback();throw$e;}
}
private function calculateEmployee($period,$employee,$start,$end,$companyId,$uid)
{
$db=$this->CI->db;
$attendance=$db->query("SELECT COUNT(*) work_days,COALESCE(SUM(attendance_status IN('present','late')),0) present_days,COALESCE(SUM(attendance_status='alpha'),0) alpha_days,COALESCE(SUM(attendance_status='unpaid'),0) unpaid_days,COALESCE(SUM(late_minutes),0) late_minutes,COALESCE(SUM(early_leave_minutes),0) early_leave_minutes,COALESCE(SUM(overtime_hours),0) overtime_hours,COALESCE(SUM(checkin_time IS NULL AND attendance_status NOT IN('annual','sick','permit','maternity','holiday')),0) missing_checkin,COALESCE(SUM(checkout_time IS NULL AND attendance_status NOT IN('annual','sick','permit','maternity','holiday')),0) missing_checkout FROM k_attendances WHERE employee_id=? AND attendance_date BETWEEN ? AND ?",array($employee->id,$start,$end))->row();
$assignments=$this->resolvedAssignments($employee,$start,$end,$companyId);$basic=0;
foreach($assignments as$assignment)if($assignment->code==='BASIC'){$basic=$this->assignmentAmount($assignment,array('basic_salary'=>0));break;}
$calendarDays=max(1,(int)((strtotime($end)-strtotime($start))/86400)+1);
$vars=array('basic_salary'=>$basic,'calendar_days'=>$calendarDays,'work_days'=>(float)$attendance->work_days,'present_days'=>(float)$attendance->present_days,'alpha_days'=>(float)$attendance->alpha_days,'unpaid_days'=>(float)$attendance->unpaid_days,'unpaid_days_total'=>(float)$attendance->unpaid_days+(float)$attendance->alpha_days,'late_minutes'=>(float)$attendance->late_minutes,'early_leave_minutes'=>(float)$attendance->early_leave_minutes,'missing_checkin'=>(float)$attendance->missing_checkin,'missing_checkout'=>(float)$attendance->missing_checkout,'overtime_hours'=>(float)$attendance->overtime_hours,'overtime_rate'=>(float)(app_env('PAYROLL_OVERTIME_RATE',25000)?:25000),'daily_rate'=>$basic/$calendarDays);
$pay=$db->get_where('k_payrolls',array('company_id'=>$companyId,'payroll_period_id'=>$period->id,'employee_id'=>$employee->id))->row();
$header=array('company_id'=>$companyId,'payroll_period_id'=>$period->id,'employee_id'=>$employee->id,'basic_salary'=>$basic,'total_work_days'=>$attendance->work_days,'total_present'=>$attendance->present_days,'total_alpha'=>$attendance->alpha_days,'total_overtime_hours'=>$attendance->overtime_hours,'period_start_date'=>$start,'period_end_date'=>$end,'structure_version'=>(int)$period->calculation_version+1);
if($pay){$db->where('id',$pay->id)->update('k_payrolls',$header);$payrollId=(int)$pay->id;$db->where('payroll_id',$payrollId)->delete('payroll_calculation_lines');}else{$db->insert('k_payrolls',$header);$payrollId=(int)$db->insert_id();}
$lines=array();
foreach($assignments as$assignment)$this->mergeLine($lines,$assignment,$this->assignmentAmount($assignment,$vars),array('source_type'=>$assignment->source_type,'source_id'=>(int)$assignment->source_id,'method'=>$assignment->resolved_method,'value'=>$assignment->resolved_value));
foreach($this->attendanceRuleLines($start,$end,$companyId,$vars)as$ruleLine)$this->mergeLine($lines,$ruleLine['component'],$ruleLine['amount'],$ruleLine['snapshot'],$ruleLine['snapshot']);
$adjustments=$db->select('a.*,c.code,c.name,c.expense_account_id,c.liability_account_id')->from('payroll_period_adjustments a')->join('payroll_components c','c.id=a.component_id')->where(array('a.company_id'=>$companyId,'a.payroll_period_id'=>$period->id,'a.employee_id'=>$employee->id,'a.is_active'=>1))->get()->result();
foreach($adjustments as$adjustment){$component=(object)array('id'=>$adjustment->component_id,'code'=>$adjustment->code,'name'=>$adjustment->name,'component_type'=>$adjustment->adjustment_type,'expense_account_id'=>$adjustment->expense_account_id,'liability_account_id'=>$adjustment->liability_account_id,'resolved_method'=>'fixed','resolved_value'=>$adjustment->amount,'resolved_formula'=>null);$this->mergeLine($lines,$component,(float)$adjustment->amount,array('source_type'=>'period_adjustment','source_id'=>(int)$adjustment->id,'notes'=>$adjustment->notes));}
$earnings=0;$deductions=0;$employerCost=0;
foreach($lines as$line){if(abs($line['amount'])<.01)continue;$db->insert('payroll_calculation_lines',array('payroll_id'=>$payrollId,'component_id'=>$line['component_id'],'component_code'=>$line['component_code'],'component_name'=>$line['component_name'],'component_type'=>$line['component_type'],'calculation_method'=>$line['calculation_method'],'base_amount'=>$basic,'rate'=>$line['rate'],'quantity'=>$line['quantity'],'amount'=>round($line['amount'],2),'formula_snapshot'=>$line['formula_snapshot'],'source_snapshot'=>json_encode($line['sources'],JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),'attendance_rule_snapshot'=>$line['rule_snapshot']?json_encode($line['rule_snapshot'],JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES):null,'expense_account_id'=>$line['expense_account_id'],'liability_account_id'=>$line['liability_account_id']));if($line['component_type']==='earning')$earnings+=$line['amount'];elseif($line['component_type']==='deduction')$deductions+=$line['amount'];else$employerCost+=$line['amount'];}
$net=$earnings-$deductions;if($net<0)throw new BusinessException('Gaji neto '.$employee->full_name.' menjadi negatif. Periksa komponen dan potongannya.');
$snapshot=array('employee'=>array('id'=>(int)$employee->id,'code'=>$employee->employee_code,'name'=>$employee->full_name,'department_id'=>$employee->department_id,'position_id'=>$employee->position_id,'salary_restricted'=>(int)$employee->salary_restricted),'attendance_period'=>array('start'=>$start,'end'=>$end),'variables'=>$vars,'lines'=>array_values($lines),'gross'=>$earnings,'deductions'=>$deductions,'employer_cost'=>$employerCost,'net'=>$net,'calculated_by'=>$uid,'calculated_at'=>date('c'));
$db->where('id',$payrollId)->update('k_payrolls',array('gross_salary'=>round($earnings,2),'total_deductions'=>round($deductions,2),'total_salary'=>round($net,2),'calculation_snapshot'=>json_encode($snapshot,JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES),'version'=>$pay?((int)$pay->version+1):1));
}
private function resolvedAssignments($employee,$start,$end,$companyId)
{
$db=$this->CI->db;
$position=$db->select("x.id source_id,'position' source_type,x.component_id,x.calculation_method resolved_method,x.amount_override resolved_value,x.formula_expression resolved_formula,c.code,c.name,c.component_type,c.calculation_method component_method,c.default_value,c.fixed_amount,c.formula_expression component_formula,c.expense_account_id,c.liability_account_id")->from('position_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.company_id'=>$companyId,'x.position_id'=>$employee->position_id,'x.is_active'=>1,'c.is_active'=>1))->where('x.effective_from <=',$end)->group_start()->where('x.effective_to IS NULL',null,false)->or_where('x.effective_to >=',$start)->group_end()->order_by('x.effective_from','DESC')->get()->result();
$employeeRows=$db->select("x.id source_id,'employee' source_type,x.component_id,x.calculation_method resolved_method,x.amount_override resolved_value,x.formula_expression resolved_formula,c.code,c.name,c.component_type,c.calculation_method component_method,c.default_value,c.fixed_amount,c.formula_expression component_formula,c.expense_account_id,c.liability_account_id")->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.company_id'=>$companyId,'x.employee_id'=>$employee->id,'x.is_active'=>1,'c.is_active'=>1))->where('x.effective_from <=',$end)->group_start()->where('x.effective_to IS NULL',null,false)->or_where('x.effective_to >=',$start)->group_end()->order_by('x.effective_from','DESC')->get()->result();
$resolved=array();foreach($position as$row)if(!isset($resolved[$row->component_id]))$resolved[$row->component_id]=$row;foreach($employeeRows as$row)if(!isset($resolved[$row->component_id])||$resolved[$row->component_id]->source_type!=='employee')$resolved[$row->component_id]=$row;
foreach($resolved as$row){$row->resolved_method=$row->resolved_method?:$row->component_method?:'fixed';if($row->resolved_value===null)$row->resolved_value=$row->default_value!==null?$row->default_value:$row->fixed_amount;if(!$row->resolved_formula)$row->resolved_formula=$row->component_formula;}return array_values($resolved);
}
private function assignmentAmount($assignment,$vars)
{
$method=$assignment->resolved_method?:'fixed';$value=max(0,(float)$assignment->resolved_value);
if($method==='per_present_day')return round($value*($vars['present_days']??0),2);if($method==='per_absent_day')return round($value*($vars['unpaid_days_total']??0),2);if($method==='per_minute')return round($value*($vars['late_minutes']??0),2);if($method==='per_hour')return round($value*($vars['overtime_hours']??0),2);if($method==='percentage_basic')return round(($vars['basic_salary']??0)*$value/100,2);if($method==='formula')return max(0,$this->CI->formulaservice->calculate($assignment->resolved_formula,$vars));return round($value,2);
}
private function attendanceRuleLines($start,$end,$companyId,$vars)
{
$rows=$this->CI->db->select('r.*,c.code,c.name,c.component_type,c.expense_account_id,c.liability_account_id')->from('payroll_attendance_rules r')->join('payroll_components c','c.id=r.component_id AND c.company_id=r.company_id')->where(array('r.company_id'=>$companyId,'r.is_active'=>1,'c.is_active'=>1))->where('r.effective_from <=',$end)->group_start()->where('r.effective_to IS NULL',null,false)->or_where('r.effective_to >=',$start)->group_end()->order_by('r.effective_from','DESC')->get()->result();$seen=array();$out=array();
foreach($rows as$rule){if(isset($seen[$rule->condition_type]))continue;$seen[$rule->condition_type]=true;if($rule->calculation_method==='none')continue;$quantity=$this->conditionQuantity($rule,$vars);if($quantity<=0)continue;$value=max(0,(float)$rule->rate_value);$method=$rule->calculation_method;if($method==='fixed')$amount=$value;elseif($method==='per_minute')$amount=$value*$quantity;elseif($method==='per_hour')$amount=$value*($quantity/60);elseif($method==='per_day')$amount=$value*$quantity;elseif($method==='percentage_daily')$amount=$vars['daily_rate']*($value/100)*$quantity;elseif($method==='formula')$amount=$this->CI->formulaservice->calculate($rule->formula_expression,array_merge($vars,array('condition_quantity'=>$quantity,'rule_rate'=>$value)));else$amount=0;if($rule->maximum_amount!==null)$amount=min($amount,(float)$rule->maximum_amount);$component=(object)array('id'=>$rule->component_id,'code'=>$rule->code,'name'=>$rule->name,'component_type'=>'deduction','expense_account_id'=>$rule->expense_account_id,'liability_account_id'=>$rule->liability_account_id,'resolved_method'=>$method,'resolved_value'=>$value,'resolved_formula'=>$rule->formula_expression);$out[]=array('component'=>$component,'amount'=>round(max(0,$amount),2),'snapshot'=>array('source_type'=>'attendance_rule','source_id'=>(int)$rule->id,'condition'=>$rule->condition_type,'quantity'=>$quantity,'tolerance'=>(int)$rule->tolerance_minutes,'method'=>$method,'rate'=>$value));}return$out;
}
private function conditionQuantity($rule,$vars)
{
if($rule->condition_type==='late')return max(0,$vars['late_minutes']-(int)$rule->tolerance_minutes);if($rule->condition_type==='early_leave')return max(0,$vars['early_leave_minutes']-(int)$rule->tolerance_minutes);if($rule->condition_type==='alpha')return$vars['alpha_days'];if($rule->condition_type==='unpaid')return$vars['unpaid_days'];if($rule->condition_type==='missing_checkin')return$vars['missing_checkin'];if($rule->condition_type==='missing_checkout')return$vars['missing_checkout'];return 0;
}
private function mergeLine(&$lines,$component,$amount,$source,$ruleSnapshot=null)
{
if($amount<0)throw new BusinessException('Nominal komponen payroll tidak boleh negatif.');$key=$component->code;
if(!isset($lines[$key]))$lines[$key]=array('component_id'=>(int)(isset($component->id)?$component->id:$component->component_id),'component_code'=>$component->code,'component_name'=>$component->name,'component_type'=>$component->component_type,'calculation_method'=>$component->resolved_method?:'fixed','amount'=>0,'rate'=>isset($component->resolved_value)?$component->resolved_value:null,'quantity'=>null,'formula_snapshot'=>isset($component->resolved_formula)?$component->resolved_formula:null,'expense_account_id'=>$component->expense_account_id,'liability_account_id'=>$component->liability_account_id,'sources'=>array(),'rule_snapshot'=>array());$lines[$key]['amount']+=(float)$amount;$lines[$key]['sources'][]=$source;if($ruleSnapshot)$lines[$key]['rule_snapshot'][]=$ruleSnapshot;
}
public function workflow($id,$action,$uid,$companyId,$canManageAll)
{
$db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);$before=$period;
if($action==='revert'){if(!in_array($period->workflow_status,array('reviewed','approved'),true))throw new BusinessException('Hanya payroll Reviewed atau Approved yang dapat dikembalikan ke Draft.');$update=array('workflow_status'=>'draft','reviewed_by'=>null,'reviewed_at'=>null,'approved_by'=>null,'approved_at'=>null,'updated_at'=>date('Y-m-d H:i:s'));}
else{$map=array('review'=>array('draft','reviewed'),'approve'=>array('reviewed','approved'),'finalize'=>array('approved','final'));if(!isset($map[$action])||$period->workflow_status!==$map[$action][0])throw new BusinessException('Tahap payroll tidak sesuai. Muat ulang halaman dan periksa status terbaru.');if(in_array($action,array('approve','finalize'),true)&&$this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Periode memuat gaji terbatas dan memerlukan permission Manage All Gaji.');$settings=$db->get_where('payroll_settings',array('company_id'=>$companyId))->row();$separation=!$settings||(int)$settings->separation_of_duties===1;if($separation&&$action==='approve'&&(int)$period->reviewed_by===(int)$uid)throw new BusinessException('Reviewer dan approver harus pengguna yang berbeda.');if($separation&&$action==='finalize'&&((int)$period->reviewed_by===(int)$uid||(int)$period->approved_by===(int)$uid))throw new BusinessException('Finalizer harus berbeda dari reviewer dan approver.');$who=$action==='review'?'reviewed_by':($action==='approve'?'approved_by':'finalized_by');$when=$action==='review'?'reviewed_at':($action==='approve'?'approved_at':'finalized_at');$update=array('workflow_status'=>$map[$action][1],$who=>$uid,$when=>date('Y-m-d H:i:s'),'updated_at'=>date('Y-m-d H:i:s'));}
$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',$update);$this->CI->auditservice->record('payroll','payroll_period',$id,$action,$before,$update,$uid);if($db->trans_status()===false)throw new RuntimeException('Workflow payroll gagal disimpan.');$db->trans_commit();
}catch(Throwable$e){$db->trans_rollback();throw$e;}
}
public function post($id,$date,$uid,$companyId,$canManageAll)
{
$db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='final'||$period->journal_id)throw new BusinessException('Payroll belum Final atau sudah diposting.');if($this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Posting payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');$this->CI->fiscalperiodservice->assertOpen($date);
$rows=$db->select('l.component_code,l.component_name,l.component_type,l.expense_account_id,l.liability_account_id,SUM(l.amount) amount',false)->from('payroll_calculation_lines l')->join('k_payrolls x','x.id=l.payroll_id')->where(array('x.payroll_period_id'=>$id,'x.company_id'=>$companyId))->group_by(array('l.component_code','l.component_name','l.component_type','l.expense_account_id','l.liability_account_id'))->get()->result();$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;$journal=array();$missing=array();
foreach($rows as$row){try{$expense=$row->expense_account_id?(int)$row->expense_account_id:(int)$this->CI->accountmappingservice->get('payroll_expense');}catch(Throwable$e){$expense=0;}try{$liability=$row->liability_account_id?(int)$row->liability_account_id:(int)$this->CI->accountmappingservice->get($row->component_type==='deduction'?'payroll_tax_payable':'payroll_payable');}catch(Throwable$e){$liability=0;}if(in_array($row->component_type,array('earning','employer_cost'),true)&&!$expense)$missing[]=$row->component_name.' (akun beban)';if(in_array($row->component_type,array('deduction','employer_cost'),true)&&!$liability)$missing[]=$row->component_name.' (akun kewajiban)';if($row->component_type==='earning')$this->addJournal($journal,$expense,(float)$row->amount,0);elseif($row->component_type==='deduction')$this->addJournal($journal,$liability,0,(float)$row->amount);else{$this->addJournal($journal,$expense,(float)$row->amount,0);$this->addJournal($journal,$liability,0,(float)$row->amount);}}
try{$payable=(int)$this->CI->accountmappingservice->get('payroll_payable');}catch(Throwable$e){$payable=0;}if(!$payable)$missing[]='Hutang Gaji (payroll_payable)';else$this->addJournal($journal,$payable,0,$net);if($missing)throw new BusinessException('Mapping akun payroll belum lengkap: '.implode(', ',array_unique($missing)).'.');$debit=0;$credit=0;foreach($journal as$line){$debit+=$line['debit'];$credit+=$line['kredit'];}if(abs($debit-$credit)>.01)throw new BusinessException('Jurnal payroll tidak balance. Periksa mapping komponen.');$count=$db->where(array('payroll_period_id'=>$id,'company_id'=>$companyId))->count_all_results('k_payrolls');$journalId=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>'PAYROLL-'.$period->id,'keterangan'=>'Payroll '.$period->period_name.' untuk '.$count.' karyawan','ref_type'=>'payroll','ref_id'=>$id,'created_by'=>$uid),array_values($journal),false,true);$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'posted','journal_id'=>$journalId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'post',null,array('journal_id'=>$journalId),$uid);$db->trans_commit();return$journalId;
}catch(Throwable$e){$db->trans_rollback();throw$e;}
}
private function addJournal(&$lines,$accountId,$debit,$credit){if(!$accountId)return;if(!isset($lines[$accountId]))$lines[$accountId]=array('account_id'=>$accountId,'debit'=>0,'kredit'=>0);$lines[$accountId]['debit']+=round($debit,2);$lines[$accountId]['kredit']+=round($credit,2);}
public function pay($id,$date,$cashAccountId,$uid,$companyId,$canManageAll)
{
$db=$this->CI->db;$db->trans_begin();try{$period=$this->period($id,$companyId);if($period->workflow_status!=='posted')throw new BusinessException('Payroll belum diposting atau sudah dibayar.');if($this->hasRestrictedPayroll($id)&&!$canManageAll)throw new BusinessException('Pembayaran payroll dengan gaji terbatas memerlukan permission Manage All Gaji.');if($db->get_where('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id))->row())throw new BusinessException('Pembayaran payroll sudah tersedia.');$cashQuery=$db->where(array('id'=>$cashAccountId,'is_active'=>1));if($db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$companyId);$cash=$cashQuery->get('cash_accounts')->row();if(!$cash)throw new BusinessException('Rekening pembayaran aktif tidak ditemukan.');$this->CI->fiscalperiodservice->assertOpen($date);$net=(float)$db->select('COALESCE(SUM(total_salary),0) total',false)->get_where('k_payrolls',array('payroll_period_id'=>$id,'company_id'=>$companyId))->row()->total;if($net<=0)throw new BusinessException('Total pembayaran payroll tidak valid.');$number='PRP-'.$companyId.'-'.date('YmdHis');$journal=$this->CI->postingservice->post(array('tanggal'=>$date,'no_ref'=>$number,'keterangan'=>'Pembayaran payroll '.$period->period_name.' melalui '.$cash->name,'ref_type'=>'payroll_payment','ref_id'=>$id,'created_by'=>$uid),array(array('account_id'=>$this->CI->accountmappingservice->get('payroll_payable'),'debit'=>$net,'kredit'=>0),array('account_id'=>$cash->gl_account_id,'debit'=>0,'kredit'=>$net)),false,true);$db->insert('payroll_payments',array('company_id'=>$companyId,'payroll_period_id'=>$id,'payment_no'=>$number,'payment_date'=>$date,'cash_account_id'=>$cashAccountId,'total_amount'=>$net,'status'=>'posted','journal_id'=>$journal,'created_by'=>$uid,'approved_by'=>$uid,'posted_at'=>date('Y-m-d H:i:s')));$paymentId=(int)$db->insert_id();$db->where(array('id'=>$id,'company_id'=>$companyId))->update('k_payroll_periods',array('workflow_status'=>'paid','payment_transaction_id'=>$paymentId,'updated_at'=>date('Y-m-d H:i:s')));$this->CI->auditservice->record('payroll','payroll_period',$id,'pay',null,array('payment_id'=>$paymentId,'journal_id'=>$journal),$uid);$db->trans_commit();return$journal;
}catch(Throwable$e){$db->trans_rollback();throw$e;}
}
private function hasRestrictedPayroll($periodId){return$this->CI->db->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id')->where('p.payroll_period_id',(int)$periodId)->where('e.salary_restricted',1)->count_all_results()>0;}
}
@@ -0,0 +1,158 @@
<?php
defined('BASEPATH') OR exit('No direct script access allowed');
class UserSessionService
{
private $CI;
private $durations=array(24,72,168);
public function __construct(){ $this->CI=&get_instance(); }
public function begin($user)
{
$hours=$this->duration($user->session_duration_hours??24);
$limit=$this->deviceLimit($user->max_active_devices??1);
$loginAt=date('Y-m-d H:i:s');
$expiresAt=date('Y-m-d H:i:s',time()+$hours*3600);
$result=array('allowed'=>true,'login_key'=>bin2hex(random_bytes(32)),'login_at'=>$loginAt,'expires_at'=>$expiresAt,'duration_hours'=>$hours,'device_limit'=>$limit);
if(!$this->ready())return$result;
$db=$this->CI->db;$db->trans_begin();
try{
$db->query('SELECT id FROM users WHERE id=? FOR UPDATE',array((int)$user->id));
$this->expireStale((int)$user->id);
$active=(int)$db->where(array('user_id'=>(int)$user->id,'status'=>'active'))->where('expires_at >',date('Y-m-d H:i:s'))->count_all_results('user_login_sessions');
if($active>=$limit){
$this->attempt((int)$user->id,(string)$user->username,'device_limit','Batas perangkat aktif telah tercapai.',$active,$limit,null);
if(!$db->trans_status())throw new RuntimeException('Log pembatasan perangkat gagal disimpan.');
$db->trans_commit();
return array('allowed'=>false,'active_count'=>$active,'device_limit'=>$limit,'message'=>'Akun ini sudah aktif pada '.$active.' perangkat (batas '.$limit.'). Akhiri salah satu sesi aktif melalui menu Profil sebelum login di perangkat lain.');
}
$device=$this->device();
$db->insert('user_login_sessions',array(
'user_id'=>(int)$user->id,'login_key'=>$result['login_key'],'session_id_hash'=>$this->sessionHash(),
'login_at'=>$loginAt,'expires_at'=>$expiresAt,'last_seen_at'=>$loginAt,'ip_address'=>$device['ip'],
'user_agent'=>$device['agent'],'device_type'=>$device['type'],'platform'=>$device['platform'],
'browser'=>$device['browser'],'status'=>'active'
));
$sessionId=(int)$db->insert_id();
$this->attempt((int)$user->id,(string)$user->username,'success','Login berhasil.',$active+1,$limit,$sessionId);
if(!$db->trans_status())throw new RuntimeException('Session login gagal dicatat.');
$db->trans_commit();return$result;
}catch(Throwable$e){$db->trans_rollback();throw$e;}
}
public function recordInvalid($user,$username)
{
if(!$this->attemptsReady())return;
$id=$user?(int)$user->id:null;$limit=$user?$this->deviceLimit($user->max_active_devices??1):1;
$this->attempt($id,substr((string)$username,0,100),'invalid_credentials','Username atau password tidak valid.',0,$limit,null);
}
public function validateCurrent()
{
$userId=(int)$this->CI->session->userdata('user_id');if($userId<1)return array('valid'=>false,'reason'=>'invalid');
$expires=(string)$this->CI->session->userdata('auth_expires_at');
if($expires!==''&&strtotime($expires)<=time()){ $this->endCurrent('expired','Masa login 24 jam atau sesuai pengaturan telah berakhir.');return array('valid'=>false,'reason'=>'expired'); }
if(!$this->ready()){
if($expires===''){
$loginAt=date('Y-m-d H:i:s');$expires=date('Y-m-d H:i:s',time()+86400);
$this->CI->session->set_userdata(array('auth_started_at'=>$loginAt,'auth_expires_at'=>$expires));
}
return array('valid'=>true);
}
$loginKey=(string)$this->CI->session->userdata('auth_login_key');
if($loginKey==='')return$this->adoptExisting($userId);
$row=$this->CI->db->where(array('user_id'=>$userId,'login_key'=>$loginKey))->get('user_login_sessions')->row();
if(!$row||$row->status!=='active')return array('valid'=>false,'reason'=>'revoked');
if(strtotime($row->expires_at)<=time()){
$this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'expired','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Batas waktu login tercapai.'));
return array('valid'=>false,'reason'=>'expired');
}
$device=$this->device();
$this->CI->db->where(array('id'=>$row->id,'status'=>'active'))->update('user_login_sessions',array('session_id_hash'=>$this->sessionHash(),'last_seen_at'=>date('Y-m-d H:i:s'),'ip_address'=>$device['ip']));
$this->CI->session->set_userdata(array('auth_started_at'=>$row->login_at,'auth_expires_at'=>$row->expires_at));
return array('valid'=>true,'row'=>$row);
}
private function adoptExisting($userId)
{
$user=$this->CI->db->get_where('users',array('id'=>$userId))->row();if(!$user)return array('valid'=>false,'reason'=>'invalid');
$created=$this->begin($user);if(!$created['allowed'])return array('valid'=>false,'reason'=>'device_limit');
$this->CI->session->set_userdata(array('auth_login_key'=>$created['login_key'],'auth_started_at'=>$created['login_at'],'auth_expires_at'=>$created['expires_at']));
return array('valid'=>true,'adopted'=>true);
}
public function endCurrent($status='logged_out',$reason='Logout oleh pengguna.')
{
if(!$this->ready())return;
$key=(string)$this->CI->session->userdata('auth_login_key');$user=(int)$this->CI->session->userdata('user_id');if($key===''||$user<1)return;
$this->CI->db->where(array('user_id'=>$user,'login_key'=>$key,'status'=>'active'))->update('user_login_sessions',array('status'=>$status,'ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>substr($reason,0,255)));
}
public function activeSessions($userId)
{
if(!$this->ready())return array();$this->expireStale((int)$userId);$current=(string)$this->CI->session->userdata('auth_login_key');
$rows=$this->CI->db->where('user_id',(int)$userId)->where_in('status',array('active','logged_out','expired','revoked'))->order_by("status='active'",'DESC',false)->order_by('last_seen_at','DESC')->limit(100)->get('user_login_sessions')->result();
foreach($rows as$row)$row->is_current=hash_equals((string)$row->login_key,$current);
return$rows;
}
public function attempts($userId)
{
if(!$this->attemptsReady())return array();return$this->CI->db->where('user_id',(int)$userId)->order_by('attempted_at','DESC')->limit(100)->get('user_login_attempts')->result();
}
public function revoke($id,$userId)
{
if(!$this->ready())throw new RuntimeException('Migration keamanan session belum dijalankan.');
$row=$this->CI->db->where(array('id'=>(int)$id,'user_id'=>(int)$userId))->get('user_login_sessions')->row();
if(!$row)throw new RuntimeException('Session perangkat tidak ditemukan.');
if($row->status==='active')$this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'revoked','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Diakhiri dari pengaturan perangkat.'));
$this->deleteNativeSession($row->session_id_hash);
return(array('current'=>hash_equals((string)$row->login_key,(string)$this->CI->session->userdata('auth_login_key'))));
}
public function revokeOthers($userId)
{
if(!$this->ready())return;$current=(string)$this->CI->session->userdata('auth_login_key');
$rows=$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('login_key !=',$current)->get('user_login_sessions')->result();
foreach($rows as$row){$this->CI->db->where('id',$row->id)->update('user_login_sessions',array('status'=>'revoked','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Password akun diubah.'));$this->deleteNativeSession($row->session_id_hash);}
}
public function activeCount($userId)
{
if(!$this->ready())return 1;$this->expireStale((int)$userId);return(int)$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('expires_at >',date('Y-m-d H:i:s'))->count_all_results('user_login_sessions');
}
private function expireStale($userId)
{
if(!$this->ready())return;$this->CI->db->where(array('user_id'=>(int)$userId,'status'=>'active'))->where('expires_at <=',date('Y-m-d H:i:s'))->update('user_login_sessions',array('status'=>'expired','ended_at'=>date('Y-m-d H:i:s'),'end_reason'=>'Batas waktu login tercapai.'));
}
private function attempt($userId,$username,$result,$reason,$active,$limit,$sessionId)
{
if(!$this->attemptsReady())return;$device=$this->device();$this->CI->db->insert('user_login_attempts',array('user_id'=>$userId?:null,'username'=>substr($username,0,100),'attempted_at'=>date('Y-m-d H:i:s'),'ip_address'=>$device['ip'],'user_agent'=>$device['agent'],'device_type'=>$device['type'],'platform'=>$device['platform'],'browser'=>$device['browser'],'result'=>$result,'reason'=>substr($reason,0,255),'active_session_count'=>max(0,(int)$active),'device_limit'=>$this->deviceLimit($limit),'login_session_id'=>$sessionId));
}
private function deleteNativeSession($hash)
{
if($hash===''||!$this->CI->db->table_exists('ci_sessions'))return;$this->CI->db->query('DELETE FROM ci_sessions WHERE SHA2(id,256)=?',array($hash));
}
private function sessionHash(){return hash('sha256',(string)session_id());}
private function duration($hours){$hours=(int)$hours;return in_array($hours,$this->durations,true)?$hours:24;}
private function deviceLimit($limit){return max(1,min(10,(int)$limit));}
private function ready(){return$this->CI->db->table_exists('user_login_sessions')&&$this->CI->db->field_exists('session_duration_hours','users')&&$this->CI->db->field_exists('max_active_devices','users');}
private function attemptsReady(){return$this->CI->db->table_exists('user_login_attempts');}
private function device()
{
$agent=substr((string)$this->CI->input->user_agent(),0,512);$browser='Browser lain';$platform='Perangkat lain';$type='Desktop';
foreach(array('Edg/'=>'Microsoft Edge','OPR/'=>'Opera','Chrome/'=>'Google Chrome','Firefox/'=>'Mozilla Firefox','Safari/'=>'Safari')as$needle=>$label)if(stripos($agent,$needle)!==false){$browser=$label;break;}
foreach(array('Windows'=>'Windows','Android'=>'Android','iPhone'=>'iOS','iPad'=>'iPadOS','Macintosh'=>'macOS','Linux'=>'Linux')as$needle=>$label)if(stripos($agent,$needle)!==false){$platform=$label;break;}
if(preg_match('/iPad|Tablet/i',$agent))$type='Tablet';elseif(preg_match('/Mobile|Android|iPhone/i',$agent))$type='Mobile';
return array('ip'=>substr((string)$this->CI->input->ip_address(),0,45),'agent'=>$agent,'browser'=>$browser,'platform'=>$platform,'type'=>$type);
}
}