fix(attendance): resolve absent status on active clock-in, add auto-logout after 12h, support single punches in BiometricAttendanceController
This commit is contained in:
@@ -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.'));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user