Files
2026-09-15 10:38:43 +07:00

162 lines
27 KiB
PHP

<?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','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.');
$employmentEmployees=$db
->where('company_id',$companyId)
->where('join_date <=',$end)
->group_start()
->where('is_active',1)
->or_where('resign_date >=',$start)
->group_end()
->group_start()
->where('resign_date IS NULL',null,false)
->or_where('resign_date','0000-00-00')
->or_where('resign_date >=',$start)
->group_end()
->order_by('full_name')
->get('k_employees')
->result();
if(!$canManageAll){foreach($employmentEmployees as$employee)if(!empty($employee->salary_restricted))throw new BusinessException('Periode memuat gaji terbatas. Perhitungan penuh harus dijalankan oleh pengguna dengan permission Manage All Gaji.');}
$this->removeIneligibleDraftPayrolls($period->id,$companyId,$employmentEmployees);
$employees=$employmentEmployees;
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 removeIneligibleDraftPayrolls($periodId,$companyId,$eligibleEmployees)
{
$eligibleIds=array_map(function($employee){return(int)$employee->id;},$eligibleEmployees);
$query=$this->CI->db
->select('id')
->where(array('company_id'=>(int)$companyId,'payroll_period_id'=>(int)$periodId));
if($eligibleIds)$query->where_not_in('employee_id',$eligibleIds);
$stale=$query->get('k_payrolls')->result();
foreach($stale as$row){
$this->CI->db->where('payroll_id',(int)$row->id)->delete('payroll_calculation_lines');
$this->CI->db->where('id',(int)$row->id)->delete('k_payrolls');
}
}
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($action==='review')$this->assertCalculationReady($period,$companyId);if(in_array($action,array('review','approve','finalize'),true)&&$this->hasRestrictedPayroll($id,$companyId)&&!$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,$companyId)&&!$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);}}
if(!$rows||$net<=0)throw new BusinessException('Hasil payroll kosong atau total neto tidak valid. Hitung ulang payroll sebelum posting.');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);if($db->trans_status()===false)throw new RuntimeException('Posting payroll gagal disimpan.');$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,$companyId)&&!$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.');if(empty($cash->gl_account_id))throw new BusinessException('Rekening pembayaran belum terhubung ke akun buku besar.');$accountQuery=$db->where(array('id'=>(int)$cash->gl_account_id,'company_id'=>$companyId,'is_active'=>1));if($db->field_exists('allow_posting','accounts'))$accountQuery->where('allow_posting',1);if(!$accountQuery->count_all_results('accounts'))throw new BusinessException('Akun buku besar pada rekening pembayaran tidak aktif, tidak dapat diposting, atau berbeda perusahaan.');$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').'-'.strtoupper(bin2hex(random_bytes(2)));$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);if($db->trans_status()===false)throw new RuntimeException('Pembayaran payroll gagal disimpan.');$db->trans_commit();return$journal;
}catch(Throwable$e){$db->trans_rollback();throw$e;}
}
private function assertCalculationReady($period,$companyId)
{
if(empty($period->calculated_at)||empty($period->calculation_hash))throw new BusinessException('Payroll belum dihitung atau berubah setelah perhitungan. Silakan Hitung Ulang.');
$actual=(int)$this->CI->db->where(array('company_id'=>$companyId,'payroll_period_id'=>$period->id))->count_all_results('k_payrolls');
if($actual<1)throw new BusinessException('Rincian payroll masih kosong. Silakan Hitung Ulang.');
$current=hash('sha256',json_encode($this->CI->db->select('id,total_salary,version')->where(array('payroll_period_id'=>$period->id,'company_id'=>$companyId))->order_by('id')->get('k_payrolls')->result_array()));
if(!hash_equals((string)$period->calculation_hash,$current))throw new BusinessException('Hasil payroll berubah setelah perhitungan. Silakan Hitung Ulang sebelum review.');
}
private function hasRestrictedPayroll($periodId,$companyId){return$this->CI->db->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'=>(int)$periodId,'p.company_id'=>(int)$companyId,'e.salary_restricted'=>1))->count_all_results()>0;}
}