diff --git a/app/Http/Controllers/BiometricAttendanceController.php b/app/Http/Controllers/BiometricAttendanceController.php index 394d25b82..d1120ff62 100644 --- a/app/Http/Controllers/BiometricAttendanceController.php +++ b/app/Http/Controllers/BiometricAttendanceController.php @@ -15,6 +15,62 @@ use Illuminate\Pagination\Paginator; class BiometricAttendanceController extends Controller { + /** + * Helper to get the correct work (shift) date for a biometric punch. + * Takes night shifts and a 4-hour window buffer into account. + */ + public static function getWorkDateForPunch($punchTime, $employee) + { + $punchTime = Carbon::parse($punchTime); + + $shift = null; + if ($employee) { + $shift = $employee->relationLoaded('shift') ? $employee->shift : Shift::find($employee->shift_id); + } + + if (!$shift) { + // Fallback to calendar day if no shift is assigned + return $punchTime->format('Y-m-d'); + } + + $graceHours = 4; // Buffer window hours + + $candidateDates = [ + $punchTime->copy()->subDay()->format('Y-m-d'), + $punchTime->format('Y-m-d'), + $punchTime->copy()->addDay()->format('Y-m-d'), + ]; + + $bestDate = null; + $minDiffMinutes = null; + + foreach ($candidateDates as $dateStr) { + $expectedStart = Carbon::parse($dateStr . ' ' . $shift->start_time); + $expectedEnd = Carbon::parse($dateStr . ' ' . $shift->end_time); + + if ($shift->is_night_shift && $expectedEnd->lt($expectedStart)) { + $expectedEnd->addDay(); + } + + $windowStart = $expectedStart->copy()->subHours($graceHours); + $windowEnd = $expectedEnd->copy()->addHours($graceHours); + + if ($punchTime->between($windowStart, $windowEnd)) { + // Calculate distance to closest expected time boundary + $diffStart = abs($punchTime->diffInMinutes($expectedStart)); + $diffEnd = abs($punchTime->diffInMinutes($expectedEnd)); + $currentDiff = min($diffStart, $diffEnd); + + if (is_null($bestDate) || $currentDiff < $minDiffMinutes) { + $bestDate = $dateStr; + $minDiffMinutes = $currentDiff; + } + } + } + + return $bestDate ?? $punchTime->format('Y-m-d'); + } + public function index(Request $request) { if (!Auth::user()->can('manage-biometric-attendance')) { @@ -29,36 +85,58 @@ class BiometricAttendanceController extends Controller } if ($request->has('end_date') && !empty($request->end_date)) { - $query->whereDate('punch_time', '<=', $request->end_date); + // Extend query date check by 1 day to fetch cross-midnight clock outs + $endDateExtended = Carbon::parse($request->end_date)->addDay()->format('Y-m-d'); + $query->whereDate('punch_time', '<=', $endDateExtended); } $records = $query->get(); + // Eager-load employees with shifts to prevent N+1 queries + $empIds = $records->pluck('biometric_emp_id')->unique(); + $employees = \App\Models\Employee::with(['user', 'shift']) + ->whereIn('biometric_emp_id', $empIds) + ->get() + ->keyBy('biometric_emp_id'); + $groupedAttendances = collect($records) - ->groupBy(function ($item) { - return $item->biometric_emp_id . '_' . $item->punch_time->format('Y-m-d'); + ->groupBy(function ($item) use ($employees) { + $employee = $employees->get($item->biometric_emp_id); + $workDate = self::getWorkDateForPunch($item->punch_time, $employee); + return $item->biometric_emp_id . '_' . $workDate; }) - ->map(function ($dayEntries) { + ->map(function ($dayEntries) use ($employees) { $sorted = $dayEntries->sortBy('punch_time'); $firstEntry = $sorted->first(); $lastEntry = $sorted->last(); + + $employee = $employees->get($firstEntry->biometric_emp_id); + $workDate = self::getWorkDateForPunch($firstEntry->punch_time, $employee); return [ 'id' => $firstEntry->id, 'employee_code' => $firstEntry->biometric_emp_id, - 'date' => $firstEntry->punch_time->format('Y-m-d'), + 'date' => $workDate, 'clock_in' => $firstEntry->punch_time->format('H:i:s'), 'clock_out' => $sorted->count() > 1 ? $lastEntry->punch_time->format('H:i:s') : null, 'total_entries' => $sorted->count(), 'terminal' => $firstEntry->terminal_alias ?? 'Agent', 'sync_status' => $firstEntry->sync_status, ]; - })->filter()->values(); + }) + ->filter(function ($item) use ($request) { + // Filter the final groups to make sure work date is strictly in the requested filter window + if ($request->has('start_date') && !empty($request->start_date)) { + if ($item['date'] < $request->start_date) return false; + } + if ($request->has('end_date') && !empty($request->end_date)) { + if ($item['date'] > $request->end_date) return false; + } + return true; + }) + ->values(); // Attach employee names - $empIds = $groupedAttendances->pluck('employee_code')->unique(); - $employees = \App\Models\Employee::with('user')->whereIn('biometric_emp_id', $empIds)->get()->keyBy('biometric_emp_id'); - $groupedAttendances = $groupedAttendances->map(function($item) use ($employees) { $emp = $employees->get($item['employee_code']); $item['name'] = $emp && $emp->user ? $emp->user->name : 'Unknown'; @@ -116,12 +194,23 @@ class BiometricAttendanceController extends Controller ], 403); } + $employee = Employee::with('shift')->where('biometric_emp_id', $employeeCode)->first(); + + // Fetch records +/- 1 day around the requested work date to catch cross-midnight punches + $startDate = Carbon::parse($date)->subDay()->startOfDay(); + $endDate = Carbon::parse($date)->addDay()->endOfDay(); + $attendances = \App\Models\BiometricAttendance::where('biometric_emp_id', $employeeCode) - ->whereDate('punch_time', $date) + ->whereBetween('punch_time', [$startDate, $endDate]) ->orderBy('punch_time', 'asc') ->get(); - $dayEntries = $attendances->map(function ($item) { + // Filter in PHP to only include punches mapping to the target work date + $filteredAttendances = $attendances->filter(function ($item) use ($employee, $date) { + return self::getWorkDateForPunch($item->punch_time, $employee) === $date; + }); + + $dayEntries = $filteredAttendances->map(function ($item) { return [ 'id' => $item->id, 'punch_time' => $item->punch_time->format('Y-m-d H:i:s'), @@ -130,7 +219,7 @@ class BiometricAttendanceController extends Controller 'verify_type_display' => 'Unknown', 'terminal_alias' => $item->terminal_alias ?? 'Unknown' ]; - }); + })->values(); if ($dayEntries->isEmpty()) { return response()->json([ @@ -173,13 +262,40 @@ class BiometricAttendanceController extends Controller $query->whereDate('punch_time', '>=', $request->start_date); } if ($request->has('end_date') && !empty($request->end_date)) { - $query->whereDate('punch_time', '<=', $request->end_date); + // Extend query date check by 1 day to fetch cross-midnight clock outs + $endDateExtended = Carbon::parse($request->end_date)->addDay()->format('Y-m-d'); + $query->whereDate('punch_time', '<=', $endDateExtended); } $pendingRecords = $query->get(); + + // Eager-load employees with shifts + $empIds = $pendingRecords->pluck('biometric_emp_id')->unique(); + $employees = Employee::with(['user', 'shift']) + ->whereIn('created_by', getCompanyAndUsersId()) + ->whereIn('biometric_emp_id', $empIds) + ->get() + ->keyBy('biometric_emp_id'); - $groupedAttendances = $pendingRecords->groupBy(function ($item) { - return $item->biometric_emp_id . '_' . $item->punch_time->format('Y-m-d'); + $groupedAttendances = $pendingRecords->groupBy(function ($item) use ($employees) { + $employee = $employees->get($item->biometric_emp_id); + $workDate = self::getWorkDateForPunch($item->punch_time, $employee); + return $item->biometric_emp_id . '_' . $workDate; + }); + + // Filter out groups where the calculated work date is outside range + $groupedAttendances = $groupedAttendances->filter(function ($dayEntries) use ($request, $employees) { + $first = $dayEntries->first(); + $employee = $employees->get($first->biometric_emp_id); + $workDate = self::getWorkDateForPunch($first->punch_time, $employee); + + if ($request->has('start_date') && !empty($request->start_date)) { + if ($workDate < $request->start_date) return false; + } + if ($request->has('end_date') && !empty($request->end_date)) { + if ($workDate > $request->end_date) return false; + } + return true; }); $syncedCount = 0; @@ -190,12 +306,12 @@ class BiometricAttendanceController extends Controller $firstEntry = $sorted->first(); $lastEntry = $sorted->last(); - $employee = Employee::with('user')->whereIn('created_by', getCompanyAndUsersId())->where('biometric_emp_id', $firstEntry->biometric_emp_id)->first(); + $employee = $employees->get($firstEntry->biometric_emp_id); - if ($employee && $sorted->count() > 1) { - $attedanceDate = $firstEntry->punch_time->format('Y-m-d'); + if ($employee && $sorted->count() >= 1) { + $attedanceDate = self::getWorkDateForPunch($firstEntry->punch_time, $employee); $clockInTime = $firstEntry->punch_time->format('H:i:s'); - $clockOutTime = $lastEntry->punch_time->format('H:i:s'); + $clockOutTime = $sorted->count() > 1 ? $lastEntry->punch_time->format('H:i:s') : null; $attendance = AttendanceRecord::where('employee_id', $employee->user_id) ->where('date', $attedanceDate) @@ -279,43 +395,40 @@ class BiometricAttendanceController extends Controller $clockInTime = $request->clock_in; $clockOutTime = $request->clock_out; - $employee = Employee::with('user')->whereIn('created_by', getCompanyAndUsersId())->where('biometric_emp_id', $biometricEmpId)->first(); + $employee = Employee::with(['user', 'shift'])->whereIn('created_by', getCompanyAndUsersId())->where('biometric_emp_id', $biometricEmpId)->first(); if ($employee) { - if (is_null($clockOutTime)) { - return redirect()->back()->with('error', __("Still Employee is not Clock Out. So You Can't Sync That Attedance.")); - } - // Check if record already exists $exists = AttendanceRecord::where('employee_id', $employee->user_id) ->where('date', $attedanceDate) ->whereIn('created_by', getCompanyAndUsersId()) ->exists(); + // Find all biometric entries mapping to this work date so we can mark them all synced + $startDate = Carbon::parse($attedanceDate)->subDay()->startOfDay(); + $endDate = Carbon::parse($attedanceDate)->addDay()->endOfDay(); + + $biometricEntries = \App\Models\BiometricAttendance::where('biometric_emp_id', $biometricEmpId) + ->whereBetween('punch_time', [$startDate, $endDate]) + ->get() + ->filter(function ($entry) use ($employee, $attedanceDate) { + return self::getWorkDateForPunch($entry->punch_time, $employee) === $attedanceDate; + }); + if ($exists) { // Just mark it as synced so it doesn't show up again - \App\Models\BiometricAttendance::where('biometric_emp_id', $biometricEmpId)->whereDate('punch_time', $attedanceDate)->update(['sync_status' => 'synced']); + foreach ($biometricEntries as $entry) { + $entry->update(['sync_status' => 'synced']); + } return redirect()->back()->with('error', __('Attendance record already exists for this employee and date.')); } else { $shift = Shift::where('id', $employee->shift_id) ->where('status', 'active') - ->first(); - - if (!$shift) { - $shift = Shift::whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->first(); - } + ->first() ?? Shift::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); $policy = AttendancePolicy::where('id', $employee->attendance_policy_id) ->where('status', 'active') - ->first(); - - if (!$policy) { - $policy = AttendancePolicy::whereIn('created_by', getCompanyAndUsersId()) - ->where('status', 'active') - ->first(); - } + ->first() ?? AttendancePolicy::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); $biometricRecord = \App\Models\BiometricAttendance::find($biometricId); @@ -334,10 +447,10 @@ class BiometricAttendanceController extends Controller $attendance->fresh(); // Reload to get relationships $attendance->processAttendance(); - // Mark DB entries as synced - \App\Models\BiometricAttendance::where('biometric_emp_id', $biometricEmpId) - ->whereDate('punch_time', $attedanceDate) - ->update(['sync_status' => 'synced']); + // Mark matching biometric entries as synced + foreach ($biometricEntries as $entry) { + $entry->update(['sync_status' => 'synced']); + } return redirect()->back()->with('success', __('Biometric data synced successfully.')); } @@ -380,7 +493,7 @@ class BiometricAttendanceController extends Controller return redirect()->back()->with('error', __('Clock Out time cannot be earlier than Clock In time.')); } - $employee = Employee::with('user')->whereIn('created_by', getCompanyAndUsersId())->where('biometric_emp_id', $biometricEmpId)->first(); + $employee = Employee::with(['user', 'shift'])->whereIn('created_by', getCompanyAndUsersId())->where('biometric_emp_id', $biometricEmpId)->first(); if (!$employee) { return redirect()->back()->with('error', __('Employee not found.')); @@ -426,10 +539,20 @@ class BiometricAttendanceController extends Controller $attendance->fresh(); // Reload relationships $attendance->processAttendance(); - // Mark all biometric entries for this employee and date as synced - \App\Models\BiometricAttendance::where('biometric_emp_id', $biometricEmpId) - ->whereDate('punch_time', $attedanceDate) - ->update(['sync_status' => 'synced']); + // Mark all matching biometric entries as synced + $startDate = Carbon::parse($attedanceDate)->subDay()->startOfDay(); + $endDate = Carbon::parse($attedanceDate)->addDay()->endOfDay(); + + $biometricEntries = \App\Models\BiometricAttendance::where('biometric_emp_id', $biometricEmpId) + ->whereBetween('punch_time', [$startDate, $endDate]) + ->get() + ->filter(function ($entry) use ($employee, $attedanceDate) { + return self::getWorkDateForPunch($entry->punch_time, $employee) === $attedanceDate; + }); + + foreach ($biometricEntries as $entry) { + $entry->update(['sync_status' => 'synced']); + } return redirect()->back()->with('success', __('Custom biometric data synced successfully.')); } diff --git a/app/Models/AttendanceRecord.php b/app/Models/AttendanceRecord.php index 29b584562..20abca3c6 100644 --- a/app/Models/AttendanceRecord.php +++ b/app/Models/AttendanceRecord.php @@ -301,6 +301,23 @@ class AttendanceRecord extends BaseModel */ public function processAttendance($autoCalculateStatus = true) { + // Auto-logout check for missing punch exceeding 12 hours + if ($this->clock_in && !$this->clock_out) { + $clockInDateTime = Carbon::parse($this->date->format('Y-m-d') . ' ' . $this->clock_in, 'Asia/Manila'); + $now = Carbon::now('Asia/Manila'); + $elapsedHours = $clockInDateTime->diffInHours($now, false); + + if ($elapsedHours > 12) { + // Auto logout + if ($this->shift && $this->shift->end_time) { + $this->clock_out = $this->shift->end_time; + } else { + $this->clock_out = $clockInDateTime->copy()->addHours(9)->format('H:i:s'); + } + $this->notes = 'NO out punch'; + } + } + // Step 1: Calculate total working hours first $this->calculateTotalHours(); @@ -321,8 +338,10 @@ class AttendanceRecord extends BaseModel } // Step 4: Check late arrival, early departure, and Night Differential - if ($this->clock_in && $this->clock_out) { + if ($this->clock_in) { $this->checkLateArrival(); + } + if ($this->clock_in && $this->clock_out) { $this->checkEarlyDeparture(); $this->calculateNightDifferential(); } @@ -340,6 +359,9 @@ class AttendanceRecord extends BaseModel $this->status = 'present'; } elseif ($this->total_hours >= $halfDayThreshold) { $this->status = 'half_day'; + } elseif ($this->clock_in && !$this->clock_out) { + // Clocked in but not clocked out yet (within 12 hours buffer) + $this->status = 'present'; } elseif ($this->total_hours > 0) { $this->status = 'absent'; // or mark as short_leave if needed } else { @@ -349,6 +371,9 @@ class AttendanceRecord extends BaseModel } // If record exists and times haven't changed, keep manual status + // Sync the is_absent flag + $this->is_absent = ($this->status === 'absent'); + if ($this->isDirty()) { $this->save(); } diff --git a/rollback_and_resync.php b/rollback_and_resync.php index 8f94000a4..8fad402c4 100644 --- a/rollback_and_resync.php +++ b/rollback_and_resync.php @@ -1,6 +1,6 @@ make(Illuminate\Contracts\Console\Kernel::class); $kernel->bootstrap();