75 lines
14 KiB
PHP
75 lines
14 KiB
PHP
<?php
|
|
defined('BASEPATH') OR exit('No direct script access allowed');
|
|
|
|
/** Pemeriksaan CLI terarah. Seluruh fixture selalu di-rollback. */
|
|
class Payrollcheck extends CI_Controller
|
|
{
|
|
public function audit()
|
|
{
|
|
if(!$this->input->is_cli_request())show_404();
|
|
$this->load->database();
|
|
$checks=array(
|
|
'Baris payroll lintas company / orphan'=>"SELECT COUNT(*) total FROM k_payrolls x LEFT JOIN k_payroll_periods p ON p.id=x.payroll_period_id LEFT JOIN k_employees e ON e.id=x.employee_id WHERE p.id IS NULL OR e.id IS NULL OR p.company_id<>x.company_id OR e.company_id<>x.company_id",
|
|
'Rincian kalkulasi tanpa header payroll'=>"SELECT COUNT(*) total FROM payroll_calculation_lines l LEFT JOIN k_payrolls x ON x.id=l.payroll_id WHERE x.id IS NULL",
|
|
'Penyesuaian dengan relasi/jenis tidak konsisten'=>"SELECT COUNT(*) total FROM payroll_period_adjustments a LEFT JOIN k_payroll_periods p ON p.id=a.payroll_period_id LEFT JOIN k_employees e ON e.id=a.employee_id LEFT JOIN payroll_components c ON c.id=a.component_id WHERE p.id IS NULL OR e.id IS NULL OR c.id IS NULL OR p.company_id<>a.company_id OR e.company_id<>a.company_id OR c.company_id<>a.company_id OR a.adjustment_type<>c.component_type",
|
|
'Periode lanjut proses tanpa hasil hitung'=>"SELECT COUNT(*) total FROM k_payroll_periods WHERE workflow_status IN('reviewed','approved','final','posted','paid') AND (calculated_at IS NULL OR calculation_hash IS NULL)",
|
|
'Periode posted/paid tanpa jurnal'=>"SELECT COUNT(*) total FROM k_payroll_periods p LEFT JOIN journals j ON j.id=p.journal_id WHERE p.workflow_status IN('posted','paid') AND j.id IS NULL",
|
|
'Periode paid tanpa pembayaran'=>"SELECT COUNT(*) total FROM k_payroll_periods p LEFT JOIN payroll_payments y ON y.id=p.payment_transaction_id AND y.company_id=p.company_id WHERE p.workflow_status='paid' AND y.id IS NULL",
|
|
'Rentang assignment karyawan tidak valid'=>"SELECT COUNT(*) total FROM employee_payroll_components WHERE effective_to IS NOT NULL AND effective_to<effective_from",
|
|
'Rentang template posisi tidak valid'=>"SELECT COUNT(*) total FROM position_payroll_components WHERE effective_to IS NOT NULL AND effective_to<effective_from",
|
|
'Assignment karyawan aktif saling tumpang tindih'=>"SELECT COUNT(*) total FROM employee_payroll_components a JOIN employee_payroll_components b ON b.id>a.id AND b.company_id=a.company_id AND b.employee_id=a.employee_id AND b.component_id=a.component_id AND b.is_active=1 WHERE a.is_active=1 AND a.effective_from<=COALESCE(b.effective_to,'9999-12-31') AND b.effective_from<=COALESCE(a.effective_to,'9999-12-31')",
|
|
'Template posisi aktif saling tumpang tindih'=>"SELECT COUNT(*) total FROM position_payroll_components a JOIN position_payroll_components b ON b.id>a.id AND b.company_id=a.company_id AND b.position_id=a.position_id AND b.component_id=a.component_id AND b.is_active=1 WHERE a.is_active=1 AND a.effective_from<=COALESCE(b.effective_to,'9999-12-31') AND b.effective_from<=COALESCE(a.effective_to,'9999-12-31')",
|
|
'Assignment karyawan lintas company'=>"SELECT COUNT(*) total FROM employee_payroll_components x JOIN k_employees e ON e.id=x.employee_id JOIN payroll_components c ON c.id=x.component_id WHERE e.company_id<>x.company_id OR c.company_id<>x.company_id",
|
|
'Template posisi lintas company'=>"SELECT COUNT(*) total FROM position_payroll_components x JOIN payroll_components c ON c.id=x.component_id WHERE c.company_id<>x.company_id",
|
|
'Jurnal payroll tidak balance'=>"SELECT COUNT(*) total FROM (SELECT j.id FROM journals j JOIN journal_details d ON d.journal_id=j.id WHERE j.ref_type IN('payroll','payroll_payment') GROUP BY j.id HAVING ABS(SUM(d.debit-d.kredit))>0.01) q"
|
|
);
|
|
$issues=0;foreach($checks as$name=>$sql){$count=(int)$this->db->query($sql)->row()->total;echo($count?'ISSUE':'PASS').': '.$name.' = '.$count.PHP_EOL;$issues+=$count;}
|
|
if($issues){fwrite(STDERR,'FAIL: ditemukan '.$issues.' inkonsistensi payroll. Periksa baris ISSUE sebelum proses berikutnya.'.PHP_EOL);exit(1);}echo"PASS: audit integritas payroll tidak menemukan inkonsistensi.\n";
|
|
}
|
|
|
|
public function exports()
|
|
{
|
|
if(!$this->input->is_cli_request())show_404();
|
|
$this->load->database();if(is_file(FCPATH.'vendor/autoload.php'))require_once FCPATH.'vendor/autoload.php';
|
|
$period=$this->db->order_by('id')->get('k_payroll_periods')->row();if(!$period){echo"SKIP: periode payroll belum tersedia.\n";return;}
|
|
$company=$this->db->get_where('companies',array('id'=>$period->company_id))->row();$rows=$this->db->select('x.*,e.employee_code,e.full_name,e.bank_name,e.bank_account_number')->from('k_payrolls x')->join('k_employees e','e.id=x.employee_id')->where('x.payroll_period_id',$period->id)->limit(3)->get()->result();
|
|
$html=$this->load->view('hr_payroll/register_pdf',array('period'=>$period,'rows'=>$rows,'company'=>$company),true);$pdf=new Dompdf\Dompdf();$pdf->loadHtml($html);$pdf->setPaper('A4','landscape');$pdf->render();if(strlen($pdf->output())<500)throw new RuntimeException('Render PDF payroll gagal.');
|
|
$book=new PhpOffice\PhpSpreadsheet\Spreadsheet();$book->getActiveSheet()->fromArray(array(array('Kode','Nama','Neto'),array($rows?$rows[0]->employee_code:'-',$rows?$rows[0]->full_name:'-', $rows?(float)$rows[0]->total_salary:0)),null,'A1');$file=tempnam(sys_get_temp_dir(),'payroll-check-').'.xlsx';(new PhpOffice\PhpSpreadsheet\Writer\Xlsx($book))->save($file);if(!is_file($file)||filesize($file)<1000)throw new RuntimeException('Render XLSX payroll gagal.');unlink($file);echo"PASS: satu PDF register dan satu XLSX payroll berhasil dirender; file uji dihapus.\n";
|
|
}
|
|
|
|
public function index()
|
|
{
|
|
if(!$this->input->is_cli_request())show_404();
|
|
$this->load->database();$this->load->library('PayrollService');
|
|
$company=$this->db->order_by('id')->get('companies')->row();
|
|
if(!$company){echo"SKIP: perusahaan tidak tersedia.\n";return;}
|
|
$employee=$this->db->where(array('company_id'=>$company->id,'is_active'=>1,'salary_restricted'=>0))->order_by('id')->get('k_employees')->row();
|
|
$user=$this->db->order_by('id')->get('users')->row();
|
|
if(!$employee||!$user){echo"SKIP: karyawan biasa atau user tidak tersedia.\n";return;}
|
|
$access=$this->db->get_where('user_companies',array('company_id'=>$company->id))->row();if(!$access){echo"SKIP: akses user perusahaan tidak tersedia.\n";return;}$user=$this->db->get_where('users',array('id'=>$access->user_id))->row();$this->session->set_userdata(array('company_id'=>(int)$company->id,'user_id'=>(int)$user->id));
|
|
$this->db->trans_begin(true);
|
|
try{
|
|
$start='2098-08-14';$end='2098-09-13';$now=date('Y-m-d H:i:s');
|
|
$this->db->insert('k_payroll_periods',array('company_id'=>$company->id,'period_name'=>'[TEST ROLLBACK] Flexible payroll','start_date'=>$start,'end_date'=>$end,'attendance_start_date'=>$start,'attendance_end_date'=>$end,'payment_date'=>'2098-09-25','status'=>'draft','workflow_status'=>'draft','generated_at'=>$now));$periodId=(int)$this->db->insert_id();
|
|
$componentIds=array();foreach(array(array('SMOKE_EARN','[TEST] Pendapatan','earning'),array('SMOKE_DED','[TEST] Potongan Tetap','deduction'),array('SMOKE_ALPHA','[TEST] Potongan Alpha','deduction'),array('SMOKE_BONUS','[TEST] Bonus Periode','earning'))as$component){$this->db->insert('payroll_components',array('company_id'=>$company->id,'code'=>$component[0].'-'.bin2hex(random_bytes(2)),'name'=>$component[1],'component_type'=>$component[2],'category'=>'other','calculation_method'=>'fixed','default_value'=>0,'fixed_amount'=>0,'taxable'=>0,'prorated'=>0,'is_active'=>1,'effective_from'=>$start,'created_at'=>$now));$componentIds[]=(int)$this->db->insert_id();}
|
|
foreach(array(array($componentIds[0],100),array($componentIds[1],10))as$assignment)$this->db->insert('employee_payroll_components',array('company_id'=>$company->id,'employee_id'=>$employee->id,'component_id'=>$assignment[0],'calculation_method'=>'fixed','amount_override'=>$assignment[1],'effective_from'=>$start,'is_active'=>1,'notes'=>'[TEST ROLLBACK]','created_at'=>$now));
|
|
$this->db->insert('payroll_attendance_rules',array('company_id'=>$company->id,'component_id'=>$componentIds[2],'rule_name'=>'[TEST] Alpha per hari','condition_type'=>'alpha','calculation_method'=>'per_day','rate_value'=>25,'effective_from'=>$start,'is_active'=>1,'created_at'=>$now));
|
|
$this->db->insert('payroll_period_adjustments',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'employee_id'=>$employee->id,'component_id'=>$componentIds[3],'adjustment_type'=>'earning','amount'=>50,'notes'=>'[TEST ROLLBACK] Bonus','is_active'=>1,'created_by'=>$user->id,'created_at'=>$now));
|
|
$this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-01','attendance_status'=>'alpha','late_minutes'=>0,'early_leave_minutes'=>0));
|
|
$this->db->insert('k_attendances',array('employee_id'=>$employee->id,'attendance_date'=>'2098-09-02','attendance_status'=>'late','late_minutes'=>30,'early_leave_minutes'=>0));
|
|
$this->db->insert('payroll_attendance_locks',array('company_id'=>$company->id,'payroll_period_id'=>$periodId,'start_date'=>$start,'end_date'=>$end,'status'=>'locked','locked_by'=>$user->id,'locked_at'=>$now));
|
|
$this->db->where('id',$employee->id)->update('k_employees',array('salary_restricted'=>1));$restrictedBlocked=false;try{$this->payrollservice->calculate($periodId,$user->id,false,$company->id);}catch(Throwable$expected){$restrictedBlocked=true;}if(!$restrictedBlocked)throw new RuntimeException('Perhitungan gaji terbatas tidak diblokir untuk user tanpa Manage All Gaji.');$this->db->where('id',$employee->id)->update('k_employees',array('salary_restricted'=>0,'is_active'=>0,'resign_date'=>'2098-09-01'));
|
|
$this->payrollservice->calculate($periodId,$user->id,true,$company->id);$first=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row();
|
|
$this->payrollservice->calculate($periodId,$user->id,true,$company->id);$second=$this->db->select('COUNT(*) count')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->where('p.payroll_period_id',$periodId)->get()->row();
|
|
$codes=$this->db->select('c.name,l.amount')->from('payroll_calculation_lines l')->join('k_payrolls p','p.id=l.payroll_id')->join('payroll_components c','c.id=l.component_id')->where('p.payroll_period_id',$periodId)->where_in('c.id',$componentIds)->get()->result();$found=array();foreach($codes as$row)$found[$row->name]=(float)$row->amount;
|
|
if((int)$first->count!==(int)$second->count)throw new RuntimeException('Recalculate menggandakan atau menghilangkan baris.');
|
|
if(($found['[TEST] Pendapatan']??0)!==100.0||($found['[TEST] Potongan Tetap']??0)!==10.0||($found['[TEST] Potongan Alpha']??0)!==25.0||($found['[TEST] Bonus Periode']??0)!==50.0)throw new RuntimeException('Pendapatan, potongan, aturan alpha, atau bonus tidak sesuai.');
|
|
$snapshot=$this->db->select('calculation_snapshot')->get_where('k_payrolls',array('payroll_period_id'=>$periodId,'employee_id'=>$employee->id))->row();if(!$snapshot||strpos($snapshot->calculation_snapshot,$start)===false||strpos($snapshot->calculation_snapshot,$end)===false)throw new RuntimeException('Snapshot rentang fleksibel tidak tersimpan.');
|
|
$late=$this->db->where(array('company_id'=>$company->id,'condition_type'=>'late','calculation_method'=>'none'))->count_all_results('payroll_attendance_rules');if(!$late)throw new RuntimeException('Default terlambat tanpa potongan tidak tersedia.');
|
|
$beforeJournal=(int)$this->db->count_all('journals');$this->db->where('id',$periodId)->update('k_payroll_periods',array('workflow_status'=>'final'));if((int)$this->db->count_all('journals')!==$beforeJournal)throw new RuntimeException('Finalisasi membuat jurnal sebelum Posting.');$journalId=$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);$balance=$this->db->select('SUM(debit-kredit) difference',false)->get_where('journal_details',array('journal_id'=>$journalId))->row();if(!$balance||abs((float)$balance->difference)>.01)throw new RuntimeException('Jurnal payroll tidak balance.');$doubleBlocked=false;try{$this->payrollservice->post($periodId,'2026-09-13',$user->id,$company->id,true);}catch(Throwable$expected){$doubleBlocked=true;}if(!$doubleBlocked)throw new RuntimeException('Double posting tidak diblokir.');
|
|
$cashQuery=$this->db->select('c.*')->from('cash_accounts c')->join('accounts a','a.id=c.gl_account_id')->where(array('c.is_active'=>1,'a.is_active'=>1,'a.allow_posting'=>1,'a.company_id'=>$company->id));if($this->db->field_exists('company_id','cash_accounts'))$cashQuery->where('c.company_id',$company->id);$cash=$cashQuery->order_by('c.id')->get()->row();if($cash){$paymentJournal=$this->payrollservice->pay($periodId,'2026-09-13',$cash->id,$user->id,$company->id,true);$paid=$this->db->get_where('k_payroll_periods',array('id'=>$periodId,'workflow_status'=>'paid'))->row();$payment=$this->db->get_where('payroll_payments',array('company_id'=>$company->id,'payroll_period_id'=>$periodId))->row();$paymentBalance=$this->db->select('SUM(debit-kredit) difference',false)->get_where('journal_details',array('journal_id'=>$paymentJournal))->row();if(!$paid||!$payment||!$paymentBalance||abs((float)$paymentBalance->difference)>.01)throw new RuntimeException('Pembayaran payroll atau jurnal pembayarannya tidak konsisten.');$doublePayBlocked=false;try{$this->payrollservice->pay($periodId,'2026-09-13',$cash->id,$user->id,$company->id,true);}catch(Throwable$expected){$doublePayBlocked=true;}if(!$doublePayBlocked)throw new RuntimeException('Double payment tidak diblokir.');echo"PASS: periode fleksibel, kalkulasi, posting, pembayaran, jurnal balance, dan idempotency tervalidasi.\n";}else echo"PASS: kalkulasi dan posting tervalidasi. SKIP pembayaran: Kas/Bank dengan akun GL aktif belum tersedia.\n";
|
|
}catch(Throwable$e){$this->db->trans_rollback();fwrite(STDERR,'FAIL: '.$e->getMessage().PHP_EOL);exit(1);}
|
|
$this->db->trans_rollback();echo"ROLLBACK: seluruh fixture payroll dibatalkan.\n";
|
|
}
|
|
}
|