fix(attendance): resolve absent status on active clock-in, add auto-logout after 12h, support single punches in BiometricAttendanceController

This commit is contained in:
2026-05-30 11:34:22 +08:00
parent d1a3a1245d
commit bcb81ed68a
3 changed files with 198 additions and 50 deletions

View File

@@ -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.'));
}