Files
HRM-System/app/Models/PayrollRun.php
2026-04-13 08:16:56 +08:00

311 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
class PayrollRun extends BaseModel
{
use HasFactory;
protected $fillable = [
'title',
'payroll_frequency',
'pay_period_start',
'pay_period_end',
'pay_date',
'total_gross_pay',
'total_deductions',
'total_net_pay',
'employee_count',
'status',
'notes',
'created_by',
];
protected $casts = [
'pay_period_start' => 'date:Y-m-d',
'pay_period_end' => 'date:Y-m-d',
'pay_date' => 'date:Y-m-d',
'total_gross_pay' => 'decimal:2',
'total_deductions' => 'decimal:2',
'total_net_pay' => 'decimal:2',
];
/**
* Get the payroll entries.
*/
public function payrollEntries()
{
return $this->hasMany(PayrollEntry::class);
}
/**
* Get the payslips through payroll entries.
*/
public function payslips()
{
return $this->hasManyThrough(Payslip::class, PayrollEntry::class);
}
/**
* Get the user who created the payroll run.
*/
public function creator()
{
return $this->belongsTo(User::class, 'created_by');
}
/**
* Calculate and update totals.
*/
public function calculateTotals()
{
$entries = $this->payrollEntries;
$this->total_gross_pay = $entries->sum('gross_pay');
$this->total_deductions = $entries->sum('total_deductions');
$this->total_net_pay = $entries->sum('net_pay');
$this->employee_count = $entries->count();
$this->save();
}
/**
* Process payroll for all employees.
*/
public function processPayroll()
{
if ($this->status !== 'draft') {
return false;
}
$this->status = 'draft';
$this->save();
try {
// Get all active employees
$employees = User::with('employee')->where('type', 'employee')
->whereIn('created_by', getCompanyAndUsersId())
->whereHas('employee', function ($q) {
$q->whereIn('employee_status', ['active', 'probation']);
})
->orderby('id', 'desc')
->get();
foreach ($employees as $employee) {
$this->processEmployeePayroll($employee);
}
$this->calculateTotals();
$this->status = 'completed';
$this->save();
return true;
} catch (\Exception $e) {
$this->status = 'draft';
$this->save();
throw $e;
}
}
/**
* Process payroll for individual employee.
*/
private function processEmployeePayroll($employee)
{
// Skip if entry already exists for this employee in this run
$existingEntry = PayrollEntry::where('payroll_run_id', $this->id)
->where('employee_id', $employee->id)
->exists();
if ($existingEntry) {
return;
}
// Working days config from global settings
$globalSettings = settings();
$workingDaysIndices = json_decode($globalSettings['working_days'] ?? '[]', true);
if (empty($workingDaysIndices)) {
throw new \Exception(__('Please configure working days first.'));
}
// Get active salary record — holds the components list (earnings/deductions)
// calculateAllComponents will resolve base_salary from employees.base_salary internally
$employeeSalary = EmployeeSalary::getActiveSalary($employee->id);
if (! $employeeSalary) {
return;
}
// Pass $employee so calculateAllComponents resolves base_salary from employees table
// Returns null if employee has no base_salary configured
$salaryBreakdown = $employeeSalary->calculateAllComponents($employee);
if (! $salaryBreakdown) {
return;
}
$basicSalary = (float) $salaryBreakdown['basic_salary'];
$totalEarnings = (float) $salaryBreakdown['total_earnings']; // basic + all earning components
$totalDeductions = (float) $salaryBreakdown['total_deductions']; // sum of deduction components
// ---------------------------------------------------------------
// STEP 1: Calculate total working days in pay period
// Only days matching configured working day indices are counted
// ---------------------------------------------------------------
$startDate = new \DateTime($this->pay_period_start);
$endDate = new \DateTime($this->pay_period_end);
$totalWorkingDays = 0;
for ($date = clone $startDate; $date <= $endDate; $date->modify('+1 day')) {
if (in_array((int) $date->format('w'), $workingDaysIndices)) {
$totalWorkingDays++;
}
}
// ---------------------------------------------------------------
// STEP 2: Attendance summary from attendance records
// ---------------------------------------------------------------
$attendanceRecords = AttendanceRecord::where('employee_id', $employee->id)
->whereBetween('date', [$this->pay_period_start, $this->pay_period_end])
->get();
$fullPresentDays = (int) $attendanceRecords->where('status', 'present')->count();
$halfDays = (int) $attendanceRecords->where('status', 'half_day')->count();
$absentDays = (int) $attendanceRecords->where('status', 'absent')->count();
$holidayDays = (int) $attendanceRecords->where('status', 'holiday')->count();
$overtimeHours = (float) $attendanceRecords->sum('overtime_hours');
$overtimeAmount = (float) $attendanceRecords->sum('overtime_amount');
// present_days stored = full present days + holiday days (both are fully paid)
$presentDays = $fullPresentDays + $holidayDays;
// ---------------------------------------------------------------
// STEP 3: Leave data for pay period
// ---------------------------------------------------------------
$leaveData = $this->getEmployeeLeaveData($employee->id);
$paidLeaveDays = (float) $leaveData['paid_leave_days'];
$unpaidLeaveDays = (float) $leaveData['unpaid_leave_days'];
// ---------------------------------------------------------------
// STEP 4: Effective paid days
// These are the days the employee is entitled to be paid for:
// - Full present days
// - Holiday days (company holidays = paid)
// - Approved paid leave days
// - Half days count as 0.5 each
// ---------------------------------------------------------------
$effectivePaidDays = $fullPresentDays + $holidayDays + $paidLeaveDays + ($halfDays * 0.5);
// Cap effectivePaidDays to totalWorkingDays (cannot exceed total)
$effectivePaidDays = min((float) $effectivePaidDays, (float) $totalWorkingDays);
// ---------------------------------------------------------------
// STEP 5: LOP (Loss of Pay) calculation
// LOP days = working days not covered by effective paid days
// This naturally covers absent days + any unaccounted days
// ---------------------------------------------------------------
$lopDays = max(0.0, $totalWorkingDays - $effectivePaidDays);
// ---------------------------------------------------------------
// STEP 6: Per day salary — based on basic salary only
// Used for LOP deduction and unpaid leave deduction
// ---------------------------------------------------------------
$perDaySalary = $totalWorkingDays > 0 ? round($basicSalary / $totalWorkingDays, 4) : 0.0;
// ---------------------------------------------------------------
// STEP 7: Deductions from salary
// lopDeduction = perDaySalary × lopDays
// unpaidLeaveDeduction = perDaySalary × unpaidLeaveDays
// (unpaid leaves are on top of LOP — explicit leave without pay)
// ---------------------------------------------------------------
$lopDeduction = round($perDaySalary * $lopDays, 2);
$unpaidLeaveDeduction = round($perDaySalary * $unpaidLeaveDays, 2);
// ---------------------------------------------------------------
// STEP 8: Gross and Net salary
// grossSalary = total_earnings - lopDeduction - unpaidLeaveDeduction + overtime
// netSalary = grossSalary - component deductions
// Both clamped to 0 (cannot be negative)
// ---------------------------------------------------------------
$grossSalary = $totalEarnings - $lopDeduction - $unpaidLeaveDeduction + $overtimeAmount;
$grossSalary = max(0.0, round($grossSalary, 2));
$netSalary = max(0.0, round($grossSalary - $totalDeductions, 2));
// component_earnings = all earning components excluding basic salary
$componentEarnings = round($totalEarnings - $basicSalary, 2);
// ---------------------------------------------------------------
// STEP 9: Persist payroll entry
// ---------------------------------------------------------------
PayrollEntry::create([
'payroll_run_id' => $this->id,
'employee_id' => $employee->id,
'basic_salary' => $basicSalary,
'component_earnings' => $componentEarnings,
'total_earnings' => $totalEarnings,
'total_deductions' => $totalDeductions,
'gross_pay' => $grossSalary,
'net_pay' => $netSalary,
'working_days' => $totalWorkingDays,
'present_days' => $presentDays,
'full_present_days' => $fullPresentDays,
'half_days' => $halfDays,
'holiday_days' => $holidayDays,
'paid_leave_days' => $paidLeaveDays,
'unpaid_leave_days' => $unpaidLeaveDays,
'absent_days' => $absentDays,
'overtime_hours' => $overtimeHours,
'overtime_amount' => $overtimeAmount,
'per_day_salary' => $perDaySalary,
'unpaid_leave_deduction' => $unpaidLeaveDeduction,
'earnings_breakdown' => $salaryBreakdown['earnings'],
'deductions_breakdown' => $salaryBreakdown['deductions'],
'created_by' => $this->created_by,
]);
}
/**
* Get employee leave data for pay period.
*/
private function getEmployeeLeaveData($employeeId)
{
$leaveApplications = \App\Models\LeaveApplication::where('employee_id', $employeeId)
->where('status', 'approved')
->where(function ($query) {
$query->whereBetween('start_date', [$this->pay_period_start, $this->pay_period_end])
->orWhereBetween('end_date', [$this->pay_period_start, $this->pay_period_end])
->orWhere(function ($q) {
$q->where('start_date', '<=', $this->pay_period_start)
->where('end_date', '>=', $this->pay_period_end);
});
})
->with('leaveType')
->get();
$paidLeaveDays = 0;
$unpaidLeaveDays = 0;
foreach ($leaveApplications as $leave) {
// Calculate days within pay period
$leaveStart = max($leave->start_date, $this->pay_period_start);
$leaveEnd = min($leave->end_date, $this->pay_period_end);
$leaveDays = $leaveStart->diffInDays($leaveEnd) + 1;
if ($leave->leaveType->is_paid) {
$paidLeaveDays += $leaveDays;
} else {
$unpaidLeaveDays += $leaveDays;
}
}
return [
'paid_leave_days' => $paidLeaveDays,
'unpaid_leave_days' => $unpaidLeaveDays,
];
}
}