update HR dan Payroll
This commit is contained in:
@@ -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;}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user