362 lines
12 KiB
PHP
362 lines
12 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use Carbon\Carbon;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
|
|
class AttendanceRecord extends BaseModel
|
|
{
|
|
use HasFactory;
|
|
|
|
protected $fillable = [
|
|
'employee_id',
|
|
'shift_id',
|
|
'attendance_policy_id',
|
|
'date',
|
|
'clock_in',
|
|
'clock_out',
|
|
'total_hours',
|
|
'break_hours',
|
|
'overtime_hours',
|
|
'overtime_amount',
|
|
'is_late',
|
|
'late_hours',
|
|
'is_early_departure',
|
|
'early_hours',
|
|
'is_absent',
|
|
'is_holiday',
|
|
'is_weekend',
|
|
'is_rest_day',
|
|
'status',
|
|
'leave_type_id',
|
|
'leave_application_id',
|
|
'notes',
|
|
'created_by',
|
|
'night_diff_hours',
|
|
];
|
|
|
|
protected $casts = [
|
|
'date' => 'date',
|
|
'break_hours' => 'decimal:2',
|
|
'overtime_hours' => 'decimal:2',
|
|
'overtime_amount' => 'decimal:2',
|
|
'is_late' => 'boolean',
|
|
'late_hours' => 'decimal:2',
|
|
'is_early_departure' => 'boolean',
|
|
'early_hours' => 'decimal:2',
|
|
'is_absent' => 'boolean',
|
|
'is_holiday' => 'boolean',
|
|
'is_weekend' => 'boolean',
|
|
'is_rest_day' => 'boolean',
|
|
'night_diff_hours' => 'decimal:2',
|
|
];
|
|
|
|
/**
|
|
* Get the employee.
|
|
*/
|
|
public function employee()
|
|
{
|
|
return $this->belongsTo(User::class, 'employee_id');
|
|
}
|
|
|
|
/**
|
|
* Get the shift.
|
|
*/
|
|
public function shift()
|
|
{
|
|
return $this->belongsTo(Shift::class);
|
|
}
|
|
|
|
/**
|
|
* Get the attendance policy.
|
|
*/
|
|
public function attendancePolicy()
|
|
{
|
|
return $this->belongsTo(AttendancePolicy::class);
|
|
}
|
|
|
|
/**
|
|
* Get the leave type associated with the record.
|
|
*/
|
|
public function leaveType()
|
|
{
|
|
return $this->belongsTo(LeaveType::class);
|
|
}
|
|
|
|
/**
|
|
* Get the leave application associated with the record.
|
|
*/
|
|
public function leaveApplication()
|
|
{
|
|
return $this->belongsTo(LeaveApplication::class);
|
|
}
|
|
|
|
/**
|
|
* Get the user who created the record.
|
|
*/
|
|
public function creator()
|
|
{
|
|
return $this->belongsTo(User::class, 'created_by');
|
|
}
|
|
|
|
/**
|
|
* Calculate total working hours.
|
|
*/
|
|
public function calculateTotalHours()
|
|
{
|
|
if ($this->clock_in && $this->clock_out) {
|
|
$clockIn = Carbon::parse($this->clock_in);
|
|
$clockOut = Carbon::parse($this->clock_out);
|
|
|
|
// Handle next day clock out (night shifts)
|
|
if ($clockOut->lt($clockIn)) {
|
|
$clockOut->addDay();
|
|
}
|
|
|
|
$totalMinutes = abs($clockOut->diffInMinutes($clockIn));
|
|
|
|
// Use shift's break times for accurate calculation
|
|
$breakMinutes = 0;
|
|
if ($this->shift && $this->shift->break_start_time && $this->shift->break_end_time) {
|
|
$breakStart = Carbon::parse($this->shift->break_start_time);
|
|
$breakEnd = Carbon::parse($this->shift->break_end_time);
|
|
|
|
// Handle next day break times for night shifts
|
|
if ($breakEnd->lt($breakStart)) {
|
|
$breakEnd->addDay();
|
|
}
|
|
|
|
// Only deduct break if employee worked through the break period
|
|
if ($clockIn->lte($breakStart) && $clockOut->gte($breakEnd)) {
|
|
// Worked through entire break - deduct full break
|
|
$breakMinutes = $this->shift->break_duration;
|
|
} elseif ($clockIn->lte($breakStart) && $clockOut->gt($breakStart) && $clockOut->lte($breakEnd)) {
|
|
// Left during break - deduct time spent on break
|
|
$breakMinutes = abs($clockOut->diffInMinutes($breakStart));
|
|
} elseif ($clockIn->gt($breakStart) && $clockIn->lt($breakEnd) && $clockOut->gte($breakEnd)) {
|
|
// Came during break - deduct partial break (missed part of break)
|
|
$breakMinutes = abs($breakEnd->diffInMinutes($clockIn));
|
|
} elseif ($clockIn->gt($breakStart) && $clockOut->lt($breakEnd)) {
|
|
// Came and left during break - no break deduction
|
|
$breakMinutes = 0;
|
|
}
|
|
}
|
|
|
|
$workingMinutes = max(0, $totalMinutes - $breakMinutes);
|
|
$calculatedHours = round($workingMinutes / 60, 2);
|
|
|
|
$this->attributes['total_hours'] = $calculatedHours;
|
|
$this->attributes['break_hours'] = round($breakMinutes / 60, 2);
|
|
} else {
|
|
$this->attributes['total_hours'] = 0;
|
|
$this->attributes['break_hours'] = 0;
|
|
}
|
|
|
|
return $this->attributes['total_hours'] ?? 0;
|
|
}
|
|
|
|
/**
|
|
* Check if employee is late.
|
|
*/
|
|
public function checkLateArrival()
|
|
{
|
|
if ($this->shift && $this->clock_in && $this->attendancePolicy) {
|
|
$expectedTime = \Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->shift->start_time);
|
|
$actualTime = \Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->clock_in);
|
|
|
|
$graceMinutes = $this->attendancePolicy->late_arrival_grace ?? 15;
|
|
|
|
if ($actualTime->gt($expectedTime->copy()->addMinutes($graceMinutes))) {
|
|
$this->is_late = true;
|
|
$this->late_hours = round($actualTime->diffInMinutes($expectedTime) / 60, 2);
|
|
} else {
|
|
$this->is_late = false;
|
|
$this->late_hours = 0;
|
|
}
|
|
} else {
|
|
$this->is_late = false;
|
|
$this->late_hours = 0;
|
|
}
|
|
|
|
return $this->is_late;
|
|
}
|
|
|
|
/**
|
|
* Check if employee left early.
|
|
*/
|
|
public function checkEarlyDeparture()
|
|
{
|
|
if ($this->shift && $this->clock_out && $this->clock_in && $this->attendancePolicy) {
|
|
$expectedTime = \Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->shift->end_time);
|
|
$actualTime = \Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->clock_out);
|
|
|
|
// Handle cross-midnight shift end
|
|
if (\Carbon\Carbon::parse($this->shift->end_time)->lt(\Carbon\Carbon::parse($this->shift->start_time))) {
|
|
$expectedTime->addDay();
|
|
}
|
|
if ($actualTime->lt(\Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->clock_in))) {
|
|
$actualTime->addDay();
|
|
}
|
|
|
|
$graceMinutes = $this->attendancePolicy->early_departure_grace ?? 0;
|
|
|
|
if ($actualTime->lt($expectedTime->copy()->subMinutes($graceMinutes))) {
|
|
$this->is_early_departure = true;
|
|
$this->early_hours = round($expectedTime->diffInMinutes($actualTime) / 60, 2);
|
|
} else {
|
|
$this->is_early_departure = false;
|
|
$this->early_hours = 0;
|
|
}
|
|
} else {
|
|
$this->is_early_departure = false;
|
|
$this->early_hours = 0;
|
|
}
|
|
|
|
return $this->is_early_departure;
|
|
}
|
|
|
|
/**
|
|
* Calculate Night Differential (10 PM to 6 AM).
|
|
*/
|
|
public function calculateNightDifferential()
|
|
{
|
|
if (!$this->clock_in || !$this->clock_out) {
|
|
$this->night_diff_hours = 0;
|
|
return 0;
|
|
}
|
|
|
|
$clockIn = \Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->clock_in);
|
|
$clockOut = \Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->clock_out);
|
|
|
|
if ($clockOut->lt($clockIn)) {
|
|
$clockOut->addDay();
|
|
}
|
|
|
|
$ndHours = 0;
|
|
|
|
// ND interval 1: Day 1, 22:00 to Day 2, 06:00
|
|
$ndStart1 = $clockIn->copy()->setTime(22, 0, 0);
|
|
$ndEnd1 = $ndStart1->copy()->addHours(8); // Day 2, 06:00
|
|
|
|
// ND interval 2: Day 0, 22:00 to Day 1, 06:00
|
|
$ndStart0 = $clockIn->copy()->subDay()->setTime(22, 0, 0);
|
|
$ndEnd0 = $ndStart0->copy()->addHours(8); // Day 1, 06:00
|
|
|
|
$calculateOverlap = function($start1, $end1, $start2, $end2) {
|
|
$latestStart = $start1->max($start2);
|
|
$earliestEnd = $end1->min($end2);
|
|
if ($latestStart->lt($earliestEnd)) {
|
|
return $latestStart->diffInMinutes($earliestEnd) / 60;
|
|
}
|
|
return 0;
|
|
};
|
|
|
|
$ndHours += $calculateOverlap($clockIn, $clockOut, $ndStart1, $ndEnd1);
|
|
$ndHours += $calculateOverlap($clockIn, $clockOut, $ndStart0, $ndEnd0);
|
|
|
|
// ND interval 3: Day 2, 22:00 to Day 3, 06:00
|
|
$ndStart2 = $clockIn->copy()->addDay()->setTime(22, 0, 0);
|
|
$ndEnd2 = $ndStart2->copy()->addHours(8);
|
|
$ndHours += $calculateOverlap($clockIn, $clockOut, $ndStart2, $ndEnd2);
|
|
|
|
// Deduct break time if break overlaps with ND period
|
|
if ($this->shift && $this->shift->break_start_time && $this->shift->break_end_time && $ndHours > 0) {
|
|
$breakStart = \Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->shift->break_start_time);
|
|
$breakEnd = \Carbon\Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->shift->break_end_time);
|
|
|
|
if ($breakEnd->lt($breakStart)) {
|
|
$breakEnd->addDay();
|
|
}
|
|
|
|
if ($breakStart->lt($clockIn->copy()->subHours(12))) {
|
|
$breakStart->addDay();
|
|
$breakEnd->addDay();
|
|
}
|
|
|
|
$breakND1 = $calculateOverlap($breakStart, $breakEnd, $ndStart1, $ndEnd1);
|
|
$breakND0 = $calculateOverlap($breakStart, $breakEnd, $ndStart0, $ndEnd0);
|
|
|
|
$ndHours -= ($breakND1 + $breakND0);
|
|
$ndHours = max(0, $ndHours);
|
|
}
|
|
|
|
$this->night_diff_hours = round($ndHours, 2);
|
|
return $this->night_diff_hours;
|
|
}
|
|
|
|
/**
|
|
* Process complete attendance - calculate everything automatically.
|
|
*/
|
|
public function processAttendance($autoCalculateStatus = true)
|
|
{
|
|
// Step 1: Calculate total working hours first
|
|
$this->calculateTotalHours();
|
|
|
|
// Step 2: Calculate overtime using shift working hours dynamically
|
|
if ($this->shift && $this->shift->working_hours > 0) {
|
|
$standardHours = $this->shift->working_hours; // Use actual shift hours
|
|
} else {
|
|
$standardHours = 8; // Fallback to 8 hours if no shift or invalid hours
|
|
}
|
|
|
|
$this->overtime_hours = max(0, round($this->total_hours - $standardHours, 2));
|
|
|
|
// Step 3: Calculate overtime amount using policy
|
|
if ($this->overtime_hours > 0 && $this->attendancePolicy) {
|
|
$this->overtime_amount = round($this->overtime_hours * $this->attendancePolicy->overtime_rate_per_hour, 2);
|
|
} else {
|
|
$this->overtime_amount = 0;
|
|
}
|
|
|
|
// Step 4: Check late arrival, early departure, and Night Differential
|
|
if ($this->clock_in && $this->clock_out) {
|
|
$this->checkLateArrival();
|
|
$this->checkEarlyDeparture();
|
|
$this->calculateNightDifferential();
|
|
}
|
|
|
|
// Step 5: Set status based on holiday or total hours (only if not manually set)
|
|
if ($autoCalculateStatus) {
|
|
if ($this->is_holiday) {
|
|
$this->status = 'holiday';
|
|
} elseif ($this->exists || $this->isDirty('clock_in') || $this->isDirty('clock_out')) {
|
|
|
|
$fullDayThreshold = $standardHours; // e.g. 8 hours
|
|
$halfDayThreshold = $standardHours / 2; // e.g. 4 hours
|
|
|
|
if ($this->total_hours >= $fullDayThreshold) {
|
|
$this->status = 'present';
|
|
} elseif ($this->total_hours >= $halfDayThreshold) {
|
|
$this->status = 'half_day';
|
|
} elseif ($this->total_hours > 0) {
|
|
$this->status = 'absent'; // or mark as short_leave if needed
|
|
} else {
|
|
$this->status = 'absent';
|
|
}
|
|
}
|
|
}
|
|
// If record exists and times haven't changed, keep manual status
|
|
|
|
if ($this->isDirty()) {
|
|
$this->save();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format clock in time for frontend (H:i format).
|
|
*/
|
|
public function getClockInAttribute($value)
|
|
{
|
|
return $value ? \Carbon\Carbon::parse($value)->format('H:i') : null;
|
|
}
|
|
|
|
/**
|
|
* Format clock out time for frontend (H:i format).
|
|
*/
|
|
public function getClockOutAttribute($value)
|
|
{
|
|
return $value ? \Carbon\Carbon::parse($value)->format('H:i') : null;
|
|
}
|
|
}
|