load->database();$this->load->library(array('PayrollService','AuditService','FormulaService')); if(is_file(FCPATH.'vendor/autoload.php'))require_once FCPATH.'vendor/autoload.php'; } private function uid(){return(int)$this->session->userdata('user_id');} private function company(){return(int)$this->session->userdata('company_id');} private function allSalary(){return is_master_admin_user()||check_permission('payroll','can_manage_all_salary');} private function canManageAllowances(){return is_master_admin_user()||check_permission('payroll','can_manage_allowances');} private function can($action){return is_master_admin_user()||check_permission('payroll',$action);} private function go($ok,$message,$section='periods'){$this->session->set_flashdata($ok?'success':'error',$message);redirect('hrpayroll?section='.$section);} private function validDate($value){$date=DateTime::createFromFormat('Y-m-d',(string)$value);return$date&&$date->format('Y-m-d')===$value?$value:null;} private function jsonError(Throwable$e){return json_response(false,$e->getMessage(),array(),$e instanceof BusinessException?422:500);} private function requireAction($action,$message){if(!$this->can($action))throw new BusinessException($message);} private function requirePost(){if(strtoupper((string)$this->input->method())!=='POST')throw new BusinessException('Metode request tidak diizinkan.');} private function isAdditionalComponent($component){if(!$component)return false;$code=strtoupper((string)$component->code);if($code==='BASIC')return$component->component_type!=='earning';return$component->component_type==='employer_cost'||$component->component_type==='earning';} private function canManageComponent($component){return!$this->isAdditionalComponent($component)||$this->canManageAllowances();} private function assertManageComponent($component){if(!$this->canManageComponent($component))throw new BusinessException('Tunjangan, bonus, lembur, THR, dan pendapatan tambahan hanya dapat dikelola dengan permission Kelola Tunjangan & Bonus.');} private function invalidateDraftCalculations() { $this->db->where(array('company_id'=>$this->company(),'workflow_status'=>'draft'))->update('k_payroll_periods',array('calculated_at'=>null,'calculated_by'=>null,'calculation_hash'=>null,'updated_at'=>date('Y-m-d H:i:s'))); } public function index() { if(!$this->can('can_view'))show_error('Anda tidak memiliki izin melihat payroll.',403,'Akses ditolak'); $section=$this->input->get('section',true)?:'periods';if(!in_array($section,array('periods','salaries','settings','adjustments'),true))$section='periods'; $company=$this->company();$visible=$this->allSalary()?'1=1':'COALESCE(e.salary_restricted,0)=0'; $periods=$this->db->query("SELECT p.*,COUNT(CASE WHEN {$visible} THEN x.id END) employee_count,COALESCE(SUM(CASE WHEN {$visible} THEN x.gross_salary ELSE 0 END),0) gross,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_deductions ELSE 0 END),0) deductions,COALESCE(SUM(CASE WHEN {$visible} THEN x.total_salary ELSE 0 END),0) net,l.status attendance_lock_status FROM k_payroll_periods p LEFT JOIN k_payrolls x ON x.payroll_period_id=p.id LEFT JOIN k_employees e ON e.id=x.employee_id LEFT JOIN payroll_attendance_locks l ON l.payroll_period_id=p.id AND l.company_id=p.company_id WHERE p.company_id=? GROUP BY p.id,l.status ORDER BY COALESCE(p.attendance_start_date,p.start_date) DESC",array($company))->result(); $components=$this->db->where('company_id',$company)->order_by('component_type')->order_by('code')->get('payroll_components')->result(); $employees=$this->db->select('e.id,e.employee_code,e.full_name,e.department_id,e.position_id,e.is_active,e.salary_restricted,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where('e.company_id',$company)->order_by('e.full_name')->get()->result(); if(!$this->allSalary())$employees=array_values(array_filter($employees,function($e){return empty($e->salary_restricted);})); $salaryRows=$this->salarySummaries($employees,$components); $positionQuery=$this->db->select('p.id,p.code,p.position_name,p.department_id,d.department_name')->from('k_positions p')->join('k_departments d','d.id=p.department_id','left')->where('p.is_active',1);if($this->db->field_exists('company_id','k_positions'))$positionQuery->where('p.company_id',$company);$positions=$positionQuery->order_by('p.position_name')->get()->result(); $departmentQuery=$this->db->where('is_active',1);if($this->db->field_exists('company_id','k_departments'))$departmentQuery->where('company_id',$company);$departments=$departmentQuery->order_by('department_name')->get('k_departments')->result(); $positionAssignments=$this->db->select('x.*,p.position_name,c.code component_code,c.name component_name,c.component_type')->from('position_payroll_components x')->join('k_positions p','p.id=x.position_id')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where('x.company_id',$company)->order_by('p.position_name')->order_by('c.name')->get()->result(); $rules=$this->db->select('r.*,c.code component_code,c.name component_name')->from('payroll_attendance_rules r')->join('payroll_components c','c.id=r.component_id AND c.company_id=r.company_id','left')->where('r.company_id',$company)->order_by('r.condition_type')->order_by('r.effective_from','DESC')->get()->result(); $adjustments=$this->db->select('a.*,p.period_name,p.workflow_status,e.employee_code,e.full_name,c.code component_code,c.name component_name,c.component_type')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id AND p.company_id=a.company_id')->join('k_employees e','e.id=a.employee_id AND e.company_id=a.company_id')->join('payroll_components c','c.id=a.component_id AND c.company_id=a.company_id')->where('a.company_id',$company)->where('a.is_active',1)->order_by('a.id','DESC')->get()->result(); if(!$this->allSalary())$adjustments=array_values(array_filter($adjustments,function($row)use($employees){foreach($employees as$employee)if($employee->id==$row->employee_id)return true;return false;})); foreach($adjustments as$row)$row->can_manage=$this->canManageComponent($row); $cashQuery=$this->db->where('is_active',1);if($this->db->field_exists('company_id','cash_accounts'))$cashQuery->where('company_id',$company);$cash=$cashQuery->order_by('name')->get('cash_accounts')->result(); $accounts=$this->db->where(array('company_id'=>$company,'is_active'=>1))->order_by('kode_akun')->get('accounts')->result(); $settings=$this->db->get_where('payroll_settings',array('company_id'=>$company))->row(); $manageableComponents=array_values(array_filter($components,function($component){return$this->canManageComponent($component);})); $adjustmentComponents=array_values(array_filter($manageableComponents,function($component){return$component->component_type==='deduction'||($this->canManageAllowances()&&$component->component_type==='earning'&&strtoupper((string)$component->code)!=='BASIC');})); $data=array('active_menu'=>'hr_payroll','payroll_section'=>$section,'periods'=>$periods,'components'=>$components,'manageable_components'=>$manageableComponents,'adjustment_components'=>$adjustmentComponents,'employees'=>$employees,'salary_rows'=>$salaryRows,'positions'=>$positions,'departments'=>$departments,'position_assignments'=>$positionAssignments,'attendance_rules'=>$rules,'adjustments'=>$adjustments,'cash_accounts'=>$cash,'accounts'=>$accounts,'payroll_settings'=>$settings,'can_manage_all_salary'=>$this->allSalary(),'can_manage_allowances'=>$this->canManageAllowances(),'permissions'=>array('create'=>$this->can('can_create'),'update'=>$this->can('can_update'),'approve'=>$this->can('can_approve'),'post'=>$this->can('can_post'),'export'=>$this->can('can_export'))); $this->load->view('partials/header',$data);$this->load->view('hr_payroll/index',$data);$this->load->view('partials/footer'); } private function salarySummaries($employees,$components) { $componentMap=array();foreach($components as$c)$componentMap[$c->id]=$c;$today=date('Y-m-d');$rows=array(); foreach($employees as$employee){$direct=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'is_active'=>1))->where('effective_from <=',$today)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$today)->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->result();$position=$this->db->where(array('company_id'=>$this->company(),'position_id'=>$employee->position_id,'is_active'=>1))->where('effective_from <=',$today)->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$today)->group_end()->order_by('effective_from','DESC')->get('position_payroll_components')->result();$resolved=array();foreach($position as$x)if(!isset($resolved[$x->component_id]))$resolved[$x->component_id]=$x;foreach($direct as$x)$resolved[$x->component_id]=$x;$earning=0;$deduction=0;$effective=null;foreach($resolved as$x){if(!isset($componentMap[$x->component_id]))continue;$value=(float)($x->amount_override!==null?$x->amount_override:($componentMap[$x->component_id]->default_value!==null?$componentMap[$x->component_id]->default_value:$componentMap[$x->component_id]->fixed_amount));if($x->calculation_method==='fixed'){if($componentMap[$x->component_id]->component_type==='earning')$earning+=$value;elseif($componentMap[$x->component_id]->component_type==='deduction')$deduction+=$value;}if(!$effective||$x->effective_from>$effective)$effective=$x->effective_from;}$employee->fixed_earnings=$earning;$employee->fixed_deductions=$deduction;$employee->estimated_net=$earning-$deduction;$employee->effective_from=$effective;$rows[]=$employee;}return$rows; } public function create_period() { try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission membuat periode payroll.');$name=trim((string)$this->input->post('period_name',true));$start=$this->validDate($this->input->post('attendance_start_date'));$end=$this->validDate($this->input->post('attendance_end_date'));$payment=$this->validDate($this->input->post('payment_date'));if($name===''||!$start||!$end||!$payment)throw new BusinessException('Nama periode, rentang kehadiran, dan tanggal pembayaran wajib diisi.');if($end<$start)throw new BusinessException('Tanggal akhir kehadiran tidak boleh sebelum tanggal awal.');$overlap=(int)$this->db->query("SELECT COUNT(*) total FROM k_payroll_periods WHERE company_id=? AND workflow_status<>'cancelled' AND COALESCE(attendance_start_date,start_date)<=? AND COALESCE(attendance_end_date,end_date)>=?",array($this->company(),$end,$start))->row()->total;if($overlap)throw new BusinessException('Rentang kehadiran tumpang tindih dengan periode payroll lain.');$row=array('company_id'=>$this->company(),'period_name'=>$name,'start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>$payment,'notes'=>trim((string)$this->input->post('notes',true))?:null,'status'=>'draft','workflow_status'=>'draft','generated_at'=>date('Y-m-d H:i:s'));$this->db->insert('k_payroll_periods',$row);$id=(int)$this->db->insert_id();$this->auditservice->record('payroll','payroll_period',$id,'create',null,$row,$this->uid());$this->go(true,'Periode payroll berhasil dibuat.');}catch(Throwable$e){$this->go(false,$e->getMessage());} } public function lock_attendance() { try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission mengunci kehadiran payroll.');$id=(int)$this->input->post('period_id');$period=$this->period($id);if($period->workflow_status!=='draft')throw new BusinessException('Kehadiran hanya dapat dikunci ketika payroll masih Draft.');$start=$period->attendance_start_date?:$period->start_date;$end=$period->attendance_end_date?:$period->end_date;$pending=array();if($this->db->table_exists('manual_attendance_requests')){$n=$this->db->where('company_id',$this->company())->where('status','submitted')->where('attendance_date >=',$start)->where('attendance_date <=',$end)->count_all_results('manual_attendance_requests');if($n)$pending[]=$n.' absensi manual';}if($this->db->table_exists('k_leave_requests')){$n=$this->db->where('company_id',$this->company())->where('workflow_status','submitted')->where('start_date <=',$end)->where('end_date >=',$start)->count_all_results('k_leave_requests');if($n)$pending[]=$n.' cuti/izin';}if($this->db->table_exists('attendance_corrections')){$n=$this->db->from('attendance_corrections c')->join('k_attendances a','a.id=c.attendance_id')->join('k_employees e','e.id=a.employee_id')->where(array('e.company_id'=>$this->company(),'c.status'=>'submitted'))->where('a.attendance_date >=',$start)->where('a.attendance_date <=',$end)->count_all_results();if($n)$pending[]=$n.' koreksi absensi';}if($pending)throw new BusinessException('Periode belum dapat dikunci karena masih ada '.implode(', ',$pending).' menunggu approval.');$existing=$this->db->get_where('payroll_attendance_locks',array('company_id'=>$this->company(),'payroll_period_id'=>$id))->row();$row=array('company_id'=>$this->company(),'payroll_period_id'=>$id,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$this->uid(),'locked_at'=>date('Y-m-d H:i:s'),'notes'=>trim((string)$this->input->post('notes',true))?:null);$this->db->trans_begin();if($existing)$this->db->where('id',$existing->id)->update('payroll_attendance_locks',$row);else$this->db->insert('payroll_attendance_locks',$row);$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('k_payroll_periods',array('attendance_locked_by'=>$this->uid(),'attendance_locked_at'=>date('Y-m-d H:i:s'),'calculated_at'=>null,'calculated_by'=>null,'calculation_hash'=>null,'updated_at'=>date('Y-m-d H:i:s')));$this->auditservice->record('payroll','attendance_lock',$id,'lock',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Kunci kehadiran gagal disimpan.');$this->db->trans_commit();$this->go(true,'Kehadiran '.$start.' sampai '.$end.' berhasil dikunci.');}catch(Throwable$e){if($this->db->trans_status()!==null)$this->db->trans_rollback();$this->go(false,$e->getMessage());} } public function calculate(){try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission menghitung payroll.');$id=$this->payrollservice->calculate((int)$this->input->post('period_id'),$this->uid(),$this->allSalary(),$this->company());$this->go(true,'Draft payroll periode #'.$id.' berhasil dihitung.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} public function workflow($id,$action){try{$this->requirePost();$this->requireAction('can_approve','Anda tidak mempunyai permission approval payroll.');$this->payrollservice->workflow($id,$action,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Tahap payroll berhasil diperbarui.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} public function post($id){try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission posting payroll.');$date=$this->validDate($this->input->post('date'));if(!$date)throw new BusinessException('Tanggal posting tidak valid.');$this->payrollservice->post($id,$date,$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil diposting ke jurnal.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} public function pay($id){try{$this->requirePost();$this->requireAction('can_post','Anda tidak mempunyai permission membayar payroll.');$date=$this->validDate($this->input->post('date'));if(!$date)throw new BusinessException('Tanggal pembayaran tidak valid.');$this->payrollservice->pay($id,$date,(int)$this->input->post('cash_account_id'),$this->uid(),$this->company(),$this->allSalary());$this->go(true,'Payroll berhasil dibayar.');}catch(Throwable$e){$this->go(false,$e->getMessage());}} public function period_detail($id) { try{$this->requireAction('can_view','Anda tidak mempunyai permission melihat payroll.');$period=$this->period($id,false);if(!$period)return json_response(false,'Periode payroll tidak ditemukan.',array(),404);$q=$this->db->select('p.id,p.basic_salary,p.gross_salary,p.total_deductions,p.total_salary,p.total_present,p.total_alpha,p.total_overtime_hours,e.employee_code,e.full_name,e.salary_restricted')->from('k_payrolls p')->join('k_employees e','e.id=p.employee_id AND e.company_id=p.company_id')->where(array('p.payroll_period_id'=>$period->id,'p.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);return json_response(true,'Rincian payroll.',array('period'=>$period,'rows'=>$q->order_by('e.full_name')->get()->result()));}catch(Throwable$e){return$this->jsonError($e);} } public function salary_detail($employeeId) { try{$this->requireAction('can_view','Anda tidak mempunyai permission melihat payroll.');$employee=$this->employee((int)$employeeId);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$lines=$this->db->select('x.*,c.code component_code,c.name component_name,c.component_type')->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'=>$this->company(),'x.employee_id'=>$employee->id))->order_by('x.effective_from','DESC')->get()->result();foreach($lines as$line)$line->can_manage=$this->can('can_update')&&$this->canManageComponent((object)array('component_type'=>$line->component_type,'code'=>$line->component_code));return json_response(true,'Struktur gaji.',array('employee'=>$employee,'lines'=>$lines));}catch(Throwable$e){return$this->jsonError($e);} } public function save_salary_line() { try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah struktur gaji.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$row=$this->assignmentInput('employee',$employee->id);$duplicate=$this->db->get_where('employee_payroll_components',array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row();if($duplicate)throw new BusinessException('Komponen dengan tanggal mulai yang sama sudah ada. Gunakan tanggal efektif baru agar histori tidak tertimpa.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'employee_id'=>$employee->id,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('employee_payroll_components')->row();if($previous)$this->db->where('id',$previous->id)->update('employee_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('employee_payroll_components',$row);$id=(int)$this->db->insert_id();if($row['component_id']===$this->basicComponentId())$this->db->where(array('id'=>$employee->id,'company_id'=>$this->company()))->update('k_employees',array('basic_salary'=>$row['amount_override']));$this->invalidateDraftCalculations();$this->auditservice->record('payroll','employee_salary_component',$id,'create_version',$previous,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Struktur gaji gagal disimpan.');$this->db->trans_commit();return json_response(true,'Versi struktur gaji berhasil disimpan. Draft payroll wajib dihitung ulang.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);} } public function deactivate_salary_line($id) { try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah struktur gaji.');$line=$this->db->select('x.*,c.code component_code,c.component_type')->from('employee_payroll_components x')->join('payroll_components c','c.id=x.component_id AND c.company_id=x.company_id')->where(array('x.id'=>(int)$id,'x.company_id'=>$this->company()))->get()->row();if(!$line)throw new BusinessException('Komponen gaji tidak ditemukan.');$this->assertManageComponent((object)array('component_type'=>$line->component_type,'code'=>$line->component_code));$employee=$this->employee($line->employee_id);if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');if(!(int)$line->is_active)throw new BusinessException('Komponen gaji sudah nonaktif.');$update=array('is_active'=>0,'effective_to'=>date('Y-m-d'),'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->where('id',$line->id)->update('employee_payroll_components',$update);$this->invalidateDraftCalculations();$this->auditservice->record('payroll','employee_salary_component',$line->id,'deactivate',$line,$update,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Komponen gaji gagal dinonaktifkan.');$this->db->trans_commit();return json_response(true,'Komponen dinonaktifkan tanpa menghapus histori. Draft payroll wajib dihitung ulang.');}catch(Throwable$e){$this->db->trans_rollback();return$this->jsonError($e);} } public function component() { try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengelola komponen.');$id=(int)$this->input->post('id');$before=$id?$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row():null;if($id&&!$before)throw new BusinessException('Komponen tidak ditemukan.');$code=strtoupper(trim((string)$this->input->post('code',true)));$name=trim((string)$this->input->post('name',true));$type=$this->input->post('component_type');$method=$this->method($this->input->post('calculation_method'));$value=$this->decimal($this->input->post('default_value'));if($code===''||$name===''||!in_array($type,array('earning','deduction','employer_cost'),true))throw new BusinessException('Kode, nama, dan jenis komponen wajib diisi.');$proposed=(object)array('code'=>$code,'component_type'=>$type);if(!$this->canManageAllowances()){if(!$before&&$type!=='deduction')throw new BusinessException('Tanpa permission Kelola Tunjangan & Bonus, komponen baru hanya boleh berupa potongan.');if($before){$this->assertManageComponent($before);$this->assertManageComponent($proposed);if(strtoupper((string)$before->code)==='BASIC'&&$code!=='BASIC')throw new BusinessException('Kode komponen Gaji Pokok tidak boleh diubah.');}}$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'code'=>$code,'name'=>$name,'component_type'=>$type,'category'=>$this->input->post('category')?:'other','calculation_method'=>$method,'default_value'=>$value,'fixed_amount'=>$value,'formula_expression'=>$formula,'taxable'=>$this->input->post('taxable')?1:0,'prorated'=>$this->input->post('prorated')?1:0,'expense_account_id'=>$this->input->post('expense_account_id')?:null,'liability_account_id'=>$this->input->post('liability_account_id')?:null,'is_active'=>$this->input->post('is_active')?1:0,'effective_from'=>$this->validDate($this->input->post('effective_from'))?:date('Y-m-d'),'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'updated_at'=>date('Y-m-d H:i:s'));$duplicate=$this->db->where(array('company_id'=>$this->company(),'code'=>$code))->where('id !=',$id)->count_all_results('payroll_components');if($duplicate)throw new BusinessException('Kode komponen sudah digunakan.');$this->db->trans_begin();if($id)$this->db->where(array('id'=>$id,'company_id'=>$this->company()))->update('payroll_components',$row);else{$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_components',$row);$id=(int)$this->db->insert_id();}$this->invalidateDraftCalculations();$this->auditservice->record('payroll','payroll_component',$id,$before?'update':'create',$before,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Komponen payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Komponen payroll berhasil disimpan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');} } public function save_position_line() { try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah template posisi.');$positionId=(int)$this->input->post('position_id');$positionQuery=$this->db->where('id',$positionId);if($this->db->field_exists('company_id','k_positions'))$positionQuery->where('company_id',$this->company());if(!$positionId||!$positionQuery->count_all_results('k_positions'))throw new BusinessException('Posisi tidak ditemukan pada perusahaan aktif.');$row=$this->assignmentInput('position',$positionId);if($this->db->get_where('position_payroll_components',array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'effective_from'=>$row['effective_from']))->row())throw new BusinessException('Template komponen dengan tanggal efektif yang sama sudah ada.');$this->db->trans_begin();$previous=$this->db->where(array('company_id'=>$this->company(),'position_id'=>$positionId,'component_id'=>$row['component_id'],'is_active'=>1))->where('effective_from <',$row['effective_from'])->group_start()->where('effective_to IS NULL',null,false)->or_where('effective_to >=',$row['effective_from'])->group_end()->order_by('effective_from','DESC')->get('position_payroll_components')->row();if($previous)$this->db->where(array('id'=>$previous->id,'company_id'=>$this->company()))->update('position_payroll_components',array('effective_to'=>date('Y-m-d',strtotime($row['effective_from'].' -1 day')),'updated_at'=>date('Y-m-d H:i:s')));$this->db->insert('position_payroll_components',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','position_salary_component',$id,'create_version',$previous,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Template posisi gagal disimpan.');$this->db->trans_commit();$this->go(true,'Versi template posisi berhasil ditambahkan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');} } public function save_attendance_rule() { try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah aturan kehadiran payroll.');$condition=$this->input->post('condition_type');$method=$this->attendanceMethod($this->input->post('calculation_method'));$start=$this->validDate($this->input->post('effective_from'));if(!in_array($condition,array('alpha','late','early_leave','unpaid','missing_checkin','missing_checkout'),true)||!$start)throw new BusinessException('Kondisi dan tanggal efektif aturan tidak valid.');$componentId=$this->input->post('component_id')?(int)$this->input->post('component_id'):null;if($method!=='none'&&!$componentId)throw new BusinessException('Komponen potongan wajib dipilih bila aturan menghasilkan potongan.');if($componentId){$component=$this->componentRow($componentId);if($component->component_type!=='deduction')throw new BusinessException('Aturan kehadiran hanya boleh menggunakan komponen potongan.');}if($method==='none')$componentId=null;$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),'component_id'=>$componentId,'rule_name'=>trim((string)$this->input->post('rule_name',true)),'condition_type'=>$condition,'calculation_method'=>$method,'tolerance_minutes'=>max(0,(int)$this->input->post('tolerance_minutes')),'rate_value'=>$this->decimal($this->input->post('rate_value')),'maximum_amount'=>$this->input->post('maximum_amount')!==''?$this->decimal($this->input->post('maximum_amount')):null,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$this->validDate($this->input->post('effective_to'))?:null,'is_active'=>$this->input->post('is_active')?1:0,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));if($row['rule_name']==='')throw new BusinessException('Nama aturan wajib diisi.');$this->db->trans_begin();$this->db->insert('payroll_attendance_rules',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','attendance_rule',$id,'create_version',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Aturan kehadiran gagal disimpan.');$this->db->trans_commit();$this->go(true,'Versi aturan kehadiran berhasil disimpan. Draft payroll wajib dihitung ulang.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');} } public function save_adjustment() { try{$this->requirePost();$this->requireAction('can_create','Anda tidak mempunyai permission membuat penyesuaian payroll.');$period=$this->period((int)$this->input->post('payroll_period_id'),false);if(!$period)throw new BusinessException('Periode payroll tidak ditemukan.');if($period->workflow_status!=='draft')throw new BusinessException('Penyesuaian hanya dapat dibuat pada payroll Draft.');$employee=$this->employee((int)$this->input->post('employee_id'));if(!empty($employee->salary_restricted)&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$component=$this->componentRow((int)$this->input->post('component_id'));$type=$this->input->post('adjustment_type');if(!in_array($type,array('earning','deduction'),true))throw new BusinessException('Jenis penyesuaian tidak valid.');if($component->component_type!==$type)throw new BusinessException('Jenis penyesuaian harus sama dengan jenis komponen yang dipilih.');if($type==='earning'&&!$this->canManageAllowances())throw new BusinessException('Bonus dan pendapatan tambahan memerlukan permission Kelola Tunjangan & Bonus.');$amount=$this->decimal($this->input->post('amount'));$notes=trim((string)$this->input->post('notes',true));if($amount<=0||$notes==='')throw new BusinessException('Nominal harus lebih dari nol dan keterangan wajib diisi.');$row=array('company_id'=>$this->company(),'payroll_period_id'=>$period->id,'employee_id'=>$employee->id,'component_id'=>$component->id,'adjustment_type'=>$type,'amount'=>$amount,'notes'=>$notes,'is_active'=>1,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->insert('payroll_period_adjustments',$row);$id=(int)$this->db->insert_id();$this->invalidateDraftCalculations();$this->auditservice->record('payroll','period_adjustment',$id,'create',null,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Penyesuaian payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Penyesuaian periode berhasil ditambahkan. Draft payroll wajib dihitung ulang.','adjustments');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'adjustments');} } public function deactivate_adjustment($id) { try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah penyesuaian payroll.');$row=$this->db->select('a.*,p.workflow_status,e.salary_restricted,c.code component_code,c.component_type')->from('payroll_period_adjustments a')->join('k_payroll_periods p','p.id=a.payroll_period_id AND p.company_id=a.company_id')->join('k_employees e','e.id=a.employee_id AND e.company_id=a.company_id')->join('payroll_components c','c.id=a.component_id AND c.company_id=a.company_id')->where(array('a.id'=>(int)$id,'a.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Penyesuaian tidak ditemukan.');if($row->workflow_status!=='draft')throw new BusinessException('Penyesuaian payroll yang bukan Draft tidak dapat diubah.');if($row->salary_restricted&&!$this->allSalary())throw new BusinessException('Gaji karyawan ini bersifat terbatas.');$this->assertManageComponent((object)array('component_type'=>$row->component_type,'code'=>$row->component_code));$update=array('is_active'=>0,'updated_by'=>$this->uid(),'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();$this->db->where(array('id'=>$row->id,'company_id'=>$this->company()))->update('payroll_period_adjustments',$update);$this->invalidateDraftCalculations();$this->auditservice->record('payroll','period_adjustment',$row->id,'deactivate',$row,$update,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Penyesuaian payroll gagal dinonaktifkan.');$this->db->trans_commit();$this->go(true,'Penyesuaian dinonaktifkan tanpa menghapus histori. Draft payroll wajib dihitung ulang.','adjustments');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'adjustments');} } public function save_settings() { try{$this->requirePost();$this->requireAction('can_update','Anda tidak mempunyai permission mengubah pengaturan payroll.');if(!$this->canManageAllowances())throw new BusinessException('Pengaturan umum payroll hanya dapat diubah dengan permission Kelola Tunjangan & Bonus.');$day=$this->input->post('default_payment_day');$day=$day===''?null:(int)$day;if($day!==null&&($day<1||$day>31))throw new BusinessException('Tanggal pembayaran default harus 1 sampai 31.');$before=$this->db->get_where('payroll_settings',array('company_id'=>$this->company()))->row();$row=array('separation_of_duties'=>$this->input->post('separation_of_duties')?1:0,'default_payment_day'=>$day,'bank_export_note'=>trim((string)$this->input->post('bank_export_note',true))?:null,'updated_at'=>date('Y-m-d H:i:s'));$this->db->trans_begin();if($before)$this->db->where(array('id'=>$before->id,'company_id'=>$this->company()))->update('payroll_settings',$row);else{$row['company_id']=$this->company();$row['created_by']=$this->uid();$row['created_at']=date('Y-m-d H:i:s');$this->db->insert('payroll_settings',$row);}$this->auditservice->record('payroll','payroll_settings',$this->company(),'update',$before,$row,$this->uid());if($this->db->trans_status()===false)throw new RuntimeException('Pengaturan payroll gagal disimpan.');$this->db->trans_commit();$this->go(true,'Pengaturan umum payroll berhasil disimpan.','settings');}catch(Throwable$e){$this->db->trans_rollback();$this->go(false,$e->getMessage(),'settings');} } private function assignmentInput($subject,$subjectId) { $component=$this->componentRow((int)$this->input->post('component_id'));$this->assertManageComponent($component);if(!(int)$component->is_active)throw new BusinessException('Komponen payroll yang dipilih sudah nonaktif.');$method=$this->method($this->input->post('calculation_method'));$amount=$this->decimal($this->input->post('amount_override'));$start=$this->validDate($this->input->post('effective_from'));$end=$this->validDate($this->input->post('effective_to'));if(!$start)throw new BusinessException('Tanggal mulai berlaku wajib diisi.');if($end&&$end<$start)throw new BusinessException('Tanggal selesai tidak boleh sebelum tanggal mulai.');$formula=$method==='formula'?trim((string)$this->input->post('formula_expression',true))?:null:null;if($method==='formula')$this->validateFormula($formula);$row=array('company_id'=>$this->company(),$subject.'_id'=>$subjectId,'component_id'=>$component->id,'calculation_method'=>$method,'amount_override'=>$amount,'formula_expression'=>$formula,'effective_from'=>$start,'effective_to'=>$end?:null,'is_active'=>1,'notes'=>trim((string)$this->input->post('notes',true))?:null,'created_by'=>$this->uid(),'created_at'=>date('Y-m-d H:i:s'));return$row; } private function method($method){$allowed=array('fixed','per_present_day','per_absent_day','per_minute','per_hour','percentage_basic','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode perhitungan tidak valid.');return$method;} private function attendanceMethod($method){$allowed=array('none','fixed','per_minute','per_hour','per_day','percentage_daily','formula');if(!in_array($method,$allowed,true))throw new BusinessException('Metode potongan tidak valid.');return$method;} private function validateFormula($formula){if(!$formula)throw new BusinessException('Formula wajib diisi.');$variables=array('basic_salary'=>1,'calendar_days'=>1,'work_days'=>1,'present_days'=>1,'alpha_days'=>1,'unpaid_days'=>1,'unpaid_days_total'=>1,'late_minutes'=>1,'early_leave_minutes'=>1,'missing_checkin'=>1,'missing_checkout'=>1,'overtime_hours'=>1,'overtime_rate'=>1,'daily_rate'=>1,'condition_quantity'=>1,'rule_rate'=>1);$this->formulaservice->calculate($formula,$variables);} private function decimal($value){$number=(float)str_replace(array('. ',','),array('','.'),(string)$value);if($number<0)throw new BusinessException('Nominal atau tarif tidak boleh negatif.');return round($number,2);} private function employee($id){$row=$this->db->select('e.*,d.department_name,p.position_name')->from('k_employees e')->join('k_departments d','d.id=e.department_id','left')->join('k_positions p','p.id=e.position_id','left')->where(array('e.id'=>$id,'e.company_id'=>$this->company()))->get()->row();if(!$row)throw new BusinessException('Karyawan tidak ditemukan pada perusahaan aktif.');return$row;} private function componentRow($id){$row=$this->db->get_where('payroll_components',array('id'=>$id,'company_id'=>$this->company()))->row();if(!$row)throw new BusinessException('Komponen payroll tidak ditemukan.');if(!(int)$row->is_active)throw new BusinessException('Komponen payroll yang dipilih sudah nonaktif.');return$row;} private function basicComponentId(){$row=$this->db->get_where('payroll_components',array('company_id'=>$this->company(),'code'=>'BASIC'))->row();return$row?(int)$row->id:0;} private function period($id,$throw=true){$row=$this->db->get_where('k_payroll_periods',array('id'=>(int)$id,'company_id'=>$this->company()))->row();if(!$row&&$throw)throw new BusinessException('Periode payroll tidak ditemukan.');return$row;} public function slip($payrollId) { if(!$this->can('can_view'))show_error('Anda tidak memiliki izin melihat slip payroll.',403,'Akses ditolak');$payroll=$this->db->select('x.*,e.full_name,e.employee_code,e.salary_restricted,r.period_name,r.workflow_status,r.attendance_start_date,r.attendance_end_date,r.payment_date')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->join('k_payroll_periods r','r.id=x.payroll_period_id AND r.company_id=x.company_id')->where(array('x.id'=>(int)$payrollId,'x.company_id'=>$this->company()))->get()->row();if(!$payroll||!in_array($payroll->workflow_status,array('final','posted','paid'),true))show_error('Slip belum tersedia.',422);if($payroll->salary_restricted&&!$this->allSalary())show_error('Gaji ini bersifat terbatas.',403);$lines=$this->db->where('payroll_id',$payrollId)->get('payroll_calculation_lines')->result();$this->load->view('hr_payroll/slip',array('payroll'=>$payroll,'lines'=>$lines)); } public function export_pdf($id) { try{$this->requireAction('can_export','Anda tidak mempunyai permission export payroll.');$period=$this->exportablePeriod($id);$rows=$this->payrollRows($id);$company=$this->db->get_where('companies',array('id'=>$this->company()))->row();$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$dompdf=new Dompdf\Dompdf();$dompdf->loadHtml($html);$dompdf->setPaper('A4','landscape');$dompdf->render();$dompdf->stream('payroll-'.$period->id.'.pdf',array('Attachment'=>true));}catch(Throwable$e){show_error($e->getMessage(),$e instanceof BusinessException?422:500);} } public function export_excel($id,$bank='general') { try{$this->requireAction('can_export','Anda tidak mempunyai permission export payroll.');$period=$this->exportablePeriod($id);$bank=strtolower($bank);if(!in_array($bank,array('general','bri','bca'),true))throw new BusinessException('Format bank tidak tersedia.');$rows=$this->payrollRows($id,$bank==='general'?null:$bank);if(!$rows)throw new BusinessException('Tidak ada data payroll untuk format yang dipilih.');$spreadsheet=new PhpOffice\PhpSpreadsheet\Spreadsheet();$sheet=$spreadsheet->getActiveSheet();$sheet->setTitle($bank==='general'?'Payroll Register':strtoupper($bank).' Source');$headers=$bank==='general'?array('No','Kode Karyawan','Nama Karyawan','Bank','Nomor Rekening','Gaji Pokok','Bruto','Potongan','Neto','Tanggal Efektif'):array('No','Nomor Rekening','Nama Penerima','Nominal Neto','Berita Pembayaran','Kode Karyawan','Tanggal Efektif');$sheet->fromArray($headers,null,'A1');$line=2;foreach($rows as$i=>$row){$values=$bank==='general'?array($i+1,$row->employee_code,$row->full_name,$row->bank_name,$row->bank_account_number,(float)$row->basic_salary,(float)$row->gross_salary,(float)$row->total_deductions,(float)$row->total_salary,$period->payment_date):array($i+1,$row->bank_account_number,$row->bank_account_name?:$row->full_name,(float)$row->total_salary,'Gaji '.$period->period_name,$row->employee_code,$period->payment_date);$sheet->fromArray($values,null,'A'.$line++);$accountColumn=$bank==='general'?'E':'B';$sheet->setCellValueExplicit($accountColumn.($line-1),(string)$row->bank_account_number,PhpOffice\PhpSpreadsheet\Cell\DataType::TYPE_STRING);}$sheet->getStyle('A1:'.$sheet->getHighestColumn().'1')->getFont()->setBold(true);foreach(range('A',$sheet->getHighestColumn())as$column)$sheet->getColumnDimension($column)->setAutoSize(true);if($bank!=='general'){$guide=$spreadsheet->createSheet();$guide->setTitle('PETUNJUK');$guide->fromArray(array(array('File sumber '.strtoupper($bank)),array('Gunakan converter/checksum resmi portal bank perusahaan bila diwajibkan.'),array('Periksa nama, rekening, nominal, dan tanggal efektif sebelum otorisasi.')),null,'A1');$guide->getColumnDimension('A')->setWidth(100);}header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');header('Content-Disposition: attachment; filename="payroll-'.$bank.'-'.$period->id.'.xlsx"');header('Cache-Control: max-age=0');(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($spreadsheet))->save('php://output');exit;}catch(Throwable$e){show_error($e->getMessage(),$e instanceof BusinessException?422:500);} } private function exportablePeriod($id){$period=$this->period($id);if(!in_array($period->workflow_status,array('final','posted','paid'),true))throw new BusinessException('Export tersedia setelah payroll berstatus Final.');if(!$this->allSalary()&&$this->hasRestrictedPeriod($id))throw new BusinessException('Export periode ini memuat gaji terbatas.');return$period;} private function hasRestrictedPeriod($id){return$this->db->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company(),'e.salary_restricted'=>1))->count_all_results()>0;} private function payrollRows($id,$bank=null){$q=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number,e.bank_account_name,e.salary_restricted')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id AND e.company_id=x.company_id')->where(array('x.payroll_period_id'=>(int)$id,'x.company_id'=>$this->company()));if(!$this->allSalary())$q->where('e.salary_restricted',0);if($bank){$q->group_start()->like('e.bank_name',$bank);if($bank==='bri')$q->or_like('e.bank_name','Rakyat Indonesia');if($bank==='bca')$q->or_like('e.bank_name','Central Asia');$q->group_end();}$rows=$q->order_by('e.full_name')->get()->result();if($bank){$missing=array();foreach($rows as$row)if(trim((string)$row->bank_account_number)===''||trim((string)$row->bank_account_name)==='')$missing[]=$row->full_name;if($missing)throw new BusinessException('Data rekening belum lengkap: '.implode(', ',$missing).'.');}return$rows;} }