diff --git a/app/Http/Controllers/BiometricAttendanceController.php b/app/Http/Controllers/BiometricAttendanceController.php index d4b1f6c4a..ba3cdb112 100644 --- a/app/Http/Controllers/BiometricAttendanceController.php +++ b/app/Http/Controllers/BiometricAttendanceController.php @@ -25,7 +25,8 @@ class BiometricAttendanceController extends Controller $token = !empty($company_setting['zkteco_auth_token']) ? $company_setting['zkteco_auth_token'] : ''; $isZktecoSync = !empty($company_setting['isZktecoSync']) && $company_setting['isZktecoSync'] == '1'; - $configurationMissing = empty($api_urls) || empty($username) || empty($password) || empty($token) || !$isZktecoSync; + $isDirectIP = filter_var($api_urls, FILTER_VALIDATE_IP); + $configurationMissing = empty($api_urls) || (!$isDirectIP && (empty($username) || empty($password) || empty($token) || !$isZktecoSync)); if (!empty($request->start_date) && !empty($request->end_date)) { $start_date = date('Y-m-d H:i:s', strtotime($request->start_date)); @@ -37,38 +38,74 @@ class BiometricAttendanceController extends Controller $attendances = []; - if (!empty($token) && !empty($api_urls)) { - $api_url = rtrim($api_urls, '/'); - $url = $api_url . '/iclock/api/transactions/?' . http_build_query([ - 'start_time' => $start_date, - 'end_time' => $end_date, - 'page_size' => 10000, - ]); - - $curl = curl_init(); - try { - curl_setopt_array($curl, array( - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_ENCODING => '', - CURLOPT_MAXREDIRS => 10, - CURLOPT_TIMEOUT => 0, - CURLOPT_FOLLOWLOCATION => true, - CURLOPT_HTTP_VERSION => CURL_HTTP_VERSION_1_1, - CURLOPT_CUSTOMREQUEST => 'GET', - CURLOPT_HTTPHEADER => array( - 'Content-Type: application/json', - 'Authorization: Token ' . $token - ), - )); - - $response = curl_exec($curl); - curl_close($curl); - - $json_attendance = json_decode($response, true); - $attendances = $json_attendance['data'] ?? []; - } catch (\Throwable $th) { - $attendances = []; + $branches = \App\Models\Branch::whereNotNull('zkteco_ip')->whereIn('created_by', getCompanyAndUsersId())->get(); + if ($branches->isNotEmpty()) { + foreach ($branches as $branch) { + try { + $zk = new \Rats\Zkteco\Lib\Zkteco($branch->zkteco_ip); + if ($zk->connect()) { + $raw_attendance = $zk->getAttendance(); + $startTs = strtotime(strlen($start_date) == 10 ? $start_date . ' 00:00:00' : $start_date); + $endTs = strtotime(strlen($end_date) == 10 ? $end_date . ' 23:59:59' : $end_date); + foreach ($raw_attendance as $record) { + $punchTs = strtotime($record['timestamp']); + if ($punchTs >= $startTs && $punchTs <= $endTs) { + $attendances[] = [ + 'id' => $record['uid'], + 'emp_code' => (string) $record['id'], + 'punch_time' => $record['timestamp'], + 'punch_state_display' => $record['type'] == 0 ? 'Clock In' : 'Clock Out', + 'terminal_alias' => $branch->name, + 'branch_id' => $branch->id + ]; + } + } + $zk->disconnect(); + } + } catch (\Throwable $th) {} + } + } else if (!empty($api_urls)) { + if ($isDirectIP) { + try { + $zk = new \Rats\Zkteco\Lib\Zkteco($api_urls); + if ($zk->connect()) { + $raw_attendance = $zk->getAttendance(); + $startTs = strtotime(strlen($start_date) == 10 ? $start_date . ' 00:00:00' : $start_date); + $endTs = strtotime(strlen($end_date) == 10 ? $end_date . ' 23:59:59' : $end_date); + foreach ($raw_attendance as $record) { + $punchTs = strtotime($record['timestamp']); + if ($punchTs >= $startTs && $punchTs <= $endTs) { + $attendances[] = [ + 'id' => $record['uid'], + 'emp_code' => (string) $record['id'], + 'punch_time' => $record['timestamp'], + 'punch_state_display' => $record['type'] == 0 ? 'Clock In' : 'Clock Out', + 'terminal_alias' => 'Local ZKTeco' + ]; + } + } + $zk->disconnect(); + } + } catch (\Throwable $th) {} + } else if (!empty($token)) { + $api_url = rtrim($api_urls, '/'); + $url = $api_url . '/iclock/api/transactions/?' . http_build_query([ + 'start_time' => $start_date, + 'end_time' => $end_date, + 'page_size' => 10000, + ]); + $curl = curl_init(); + try { + curl_setopt_array($curl, array( + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => array('Content-Type: application/json', 'Authorization: Token ' . $token) + )); + $response = curl_exec($curl); + curl_close($curl); + $json = json_decode($response, true); + $attendances = $json['data'] ?? []; + } catch (\Throwable $th) {} } } @@ -87,7 +124,12 @@ class BiometricAttendanceController extends Controller $sorted = $dayEntries->sortBy('punch_time'); $firstEntry = $sorted->first(); $lastEntry = $sorted->last(); - $employee = Employee::with('user')->where('biometric_emp_id', $firstEntry['emp_code'])->first(); + $q = Employee::with('user')->where('biometric_emp_id', $firstEntry['emp_code']); + if (isset($firstEntry['branch_id'])) { + $q->where('branch_id', $firstEntry['branch_id']); + } + $employee = $q->first(); + if (!$employee) return null; return [ 'id' => $firstEntry['id'], @@ -97,9 +139,10 @@ class BiometricAttendanceController extends Controller 'clock_in' => date('H:i:s', strtotime($firstEntry['punch_time'])), 'clock_out' => $sorted->count() > 1 ? date('H:i:s', strtotime($lastEntry['punch_time'])) : null, 'total_entries' => $sorted->count(), + 'terminal' => $firstEntry['terminal_alias'] ?? 'Unknown' ]; - }); - $query = $groupedAttendances->values(); + })->filter()->values(); + $query = $groupedAttendances; // Handle search if ($request->has('search') && !empty($request->search)) { @@ -180,48 +223,76 @@ class BiometricAttendanceController extends Controller $token = $company_setting['zkteco_auth_token'] ?? ''; $api_urls = $company_setting['zkteco_api_url'] ?? ''; - if (empty($token) || empty($api_urls)) { + $isDirectIP = filter_var($api_urls, FILTER_VALIDATE_IP); + + if (empty($api_urls) || (!$isDirectIP && empty($token))) { return response()->json([ 'success' => false, 'message' => 'ZKTeco API configuration missing' ], 400); } - $start_date = $date . ' 00:00:00'; - $end_date = $date . ' 23:59:59'; + if ($isDirectIP) { + try { + $zk = new \Rats\Zkteco\Lib\Zkteco($api_urls); + if ($zk->connect()) { + $raw_attendance = $zk->getAttendance(); + foreach ($raw_attendance as $record) { + if ((string)$record['id'] === (string)$employeeCode && str_starts_with($record['timestamp'], $date)) { + $attendances[] = [ + 'id' => $record['uid'], + 'punch_time' => $record['timestamp'], + 'punch_state_display' => $record['type'] == 0 ? 'Clock In' : 'Clock Out', + 'verify_type_display' => 'Unknown', + 'terminal_alias' => 'Local ZKTeco' + ]; + } + } + $zk->disconnect(); + } + } catch (\Throwable $th) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to connect to local ZKTeco' + ], 500); + } + } else { + $start_date = $date . ' 00:00:00'; + $end_date = $date . ' 23:59:59'; - $api_url = rtrim($api_urls, '/'); - $url = $api_url . '/iclock/api/transactions/?' . http_build_query([ - 'emp_code' => $employeeCode, - 'start_time' => $start_date, - 'end_time' => $end_date, - 'page_size' => 1000, - ]); + $api_url = rtrim($api_urls, '/'); + $url = $api_url . '/iclock/api/transactions/?' . http_build_query([ + 'emp_code' => $employeeCode, + 'start_time' => $start_date, + 'end_time' => $end_date, + 'page_size' => 1000, + ]); - $curl = curl_init(); - curl_setopt_array($curl, [ - CURLOPT_URL => $url, - CURLOPT_RETURNTRANSFER => true, - CURLOPT_TIMEOUT => 30, - CURLOPT_HTTPHEADER => [ - 'Content-Type: application/json', - 'Authorization: Token ' . $token - ], - ]); + $curl = curl_init(); + curl_setopt_array($curl, [ + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'Authorization: Token ' . $token + ], + ]); - $response = curl_exec($curl); - $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); - curl_close($curl); + $response = curl_exec($curl); + $httpCode = curl_getinfo($curl, CURLINFO_HTTP_CODE); + curl_close($curl); - if ($httpCode !== 200 || !$response) { - return response()->json([ - 'success' => false, - 'message' => 'Failed to fetch data from ZKTeco API' - ], 500); + if ($httpCode !== 200 || !$response) { + return response()->json([ + 'success' => false, + 'message' => 'Failed to fetch data from ZKTeco API' + ], 500); + } + + $json_attendance = json_decode($response, true); + $attendances = $json_attendance['data'] ?? []; } - - $json_attendance = json_decode($response, true); - $attendances = $json_attendance['data'] ?? []; } @@ -263,6 +334,133 @@ class BiometricAttendanceController extends Controller } } + public function syncAll(Request $request) + { + if (Auth::user()->can('sync-biometric-attendance')) { + $company_setting = settings(); + $api_urls = !empty($company_setting['zkteco_api_url']) ? $company_setting['zkteco_api_url'] : ''; + $token = !empty($company_setting['zkteco_auth_token']) ? $company_setting['zkteco_auth_token'] : ''; + + $start_date = date('Y-m-d', strtotime('-7 days')); + $end_date = date('Y-m-d'); + + $isDirectIP = filter_var($api_urls, FILTER_VALIDATE_IP); + $attendances = []; + + $branches = \App\Models\Branch::whereNotNull('zkteco_ip')->whereIn('created_by', getCompanyAndUsersId())->get(); + if ($branches->isNotEmpty()) { + foreach ($branches as $branch) { + try { + $zk = new \Rats\Zkteco\Lib\Zkteco($branch->zkteco_ip); + if ($zk->connect()) { + $raw_attendance = $zk->getAttendance(); + $startTs = strtotime($start_date . ' 00:00:00'); + $endTs = strtotime($end_date . ' 23:59:59'); + foreach ($raw_attendance as $record) { + $punchTs = strtotime($record['timestamp']); + if ($punchTs >= $startTs && $punchTs <= $endTs) { + $attendances[] = [ + 'id' => $record['uid'], + 'emp_code' => (string) $record['id'], + 'punch_time' => $record['timestamp'], + 'branch_id' => $branch->id + ]; + } + } + $zk->disconnect(); + } + } catch (\Throwable $th) {} + } + } else if (!empty($api_urls)) { + if ($isDirectIP) { + try { + $zk = new \Rats\Zkteco\Lib\Zkteco($api_urls); + if ($zk->connect()) { + $raw_attendance = $zk->getAttendance(); + $startTs = strtotime($start_date . ' 00:00:00'); + $endTs = strtotime($end_date . ' 23:59:59'); + foreach ($raw_attendance as $record) { + $punchTs = strtotime($record['timestamp']); + if ($punchTs >= $startTs && $punchTs <= $endTs) { + $attendances[] = [ + 'id' => $record['uid'], + 'emp_code' => (string) $record['id'], + 'punch_time' => $record['timestamp'], + ]; + } + } + $zk->disconnect(); + } + } catch (\Throwable $th) {} + } else if (!empty($token)) { + $api_url = rtrim($api_urls, '/'); + $url = $api_url . '/iclock/api/transactions/?' . http_build_query([ + 'start_time' => date('Y-m-d H:i:s', strtotime($start_date . ' 00:00:00')), + 'end_time' => date('Y-m-d H:i:s', strtotime($end_date . ' 23:59:59')), + 'page_size' => 10000, + ]); + $curl = curl_init(); + try { + curl_setopt_array($curl, array( + CURLOPT_URL => $url, + CURLOPT_RETURNTRANSFER => true, + CURLOPT_HTTPHEADER => array('Content-Type: application/json', 'Authorization: Token ' . $token) + )); + $response = curl_exec($curl); + curl_close($curl); + $json = json_decode($response, true); + $attendances = $json['data'] ?? []; + } catch (\Throwable $th) {} + } + } + + $groupedAttendances = collect($attendances)->groupBy(function ($item) { + return $item['emp_code'] . '_' . date('Y-m-d', strtotime($item['punch_time'])); + }); + + $syncedCount = 0; + foreach ($groupedAttendances as $key => $dayEntries) { + $sorted = $dayEntries->sortBy('punch_time'); + $firstEntry = $sorted->first(); + $lastEntry = $sorted->last(); + + $q = Employee::with('user')->whereIn('created_by', getCompanyAndUsersId())->where('biometric_emp_id', $firstEntry['emp_code']); + if (isset($firstEntry['branch_id'])) { + $q->where('branch_id', $firstEntry['branch_id']); + } + $employee = $q->first(); + if ($employee && $sorted->count() > 1) { + $attedanceDate = date('Y-m-d', strtotime($firstEntry['punch_time'])); + $clockInTime = date('H:i:s', strtotime($firstEntry['punch_time'])); + $clockOutTime = date('H:i:s', strtotime($lastEntry['punch_time'])); + + $exists = AttendanceRecord::where('employee_id', $employee->user_id)->where('date', $attedanceDate)->whereIn('created_by', getCompanyAndUsersId())->exists(); + if (!$exists) { + $shift = Shift::where('id', $employee->shift_id)->where('status', 'active')->first() ?? Shift::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); + $policy = AttendancePolicy::where('id', $employee->attendance_policy_id)->where('status', 'active')->first() ?? AttendancePolicy::whereIn('created_by', getCompanyAndUsersId())->where('status', 'active')->first(); + + $attendance = new AttendanceRecord(); + $attendance->employee_id = $employee->user_id; + $attendance->biometric_id = $firstEntry['id']; + $attendance->shift_id = $shift?->id; + $attendance->attendance_policy_id = $policy?->id; + $attendance->date = $attedanceDate; + $attendance->clock_in = $clockInTime; + $attendance->clock_out = $clockOutTime; + $attendance->created_by = creatorId(); + $attendance->save(); + + $attendance->fresh(); + $attendance->processAttendance(); + $syncedCount++; + } + } + } + return redirect()->back()->with('success', __("Bulk sync completed. Synced {$syncedCount} new records.")); + } + return redirect()->back()->with('error', __('Permission Denied.')); + } + public function sync(Request $request, $id) { if (Auth::user()->can('sync-biometric-attendance')) { @@ -275,7 +473,9 @@ class BiometricAttendanceController extends Controller $company_setting = settings(); - if (empty($company_setting['zkteco_auth_token'])) { + $isDirectIP = filter_var($company_setting['zkteco_api_url'] ?? '', FILTER_VALIDATE_IP); + + if (!$isDirectIP && empty($company_setting['zkteco_auth_token'])) { return redirect()->back()->with('error', __('Create the Auth Token From the Setting page.')); } diff --git a/app/Http/Controllers/BranchController.php b/app/Http/Controllers/BranchController.php index 0d36e7082..967fa88f8 100644 --- a/app/Http/Controllers/BranchController.php +++ b/app/Http/Controllers/BranchController.php @@ -66,6 +66,7 @@ class BranchController extends Controller 'zip_code' => 'nullable|string|max:20', 'phone' => 'nullable|string|max:20', 'email' => 'nullable|email|max:255', + 'zkteco_ip' => 'nullable|string|max:255', 'status' => 'nullable|in:active,inactive', ]); @@ -111,6 +112,7 @@ class BranchController extends Controller 'zip_code' => 'nullable|string|max:20', 'phone' => 'nullable|string|max:20', 'email' => 'nullable|email|max:255', + 'zkteco_ip' => 'nullable|string|max:255', 'status' => 'nullable|in:active,inactive', ]); diff --git a/app/Http/Controllers/Settings/SettingsController.php b/app/Http/Controllers/Settings/SettingsController.php index 3a26c2936..65efc99f8 100644 --- a/app/Http/Controllers/Settings/SettingsController.php +++ b/app/Http/Controllers/Settings/SettingsController.php @@ -30,15 +30,6 @@ class SettingsController extends Controller $webhooks = Webhook::where('user_id', auth()->id())->get(); $ipRestrictions = IpRestriction::whereIn('created_by', getCompanyAndUsersId())->orderBy('id', 'desc')->get(); - // Get Zekto settings for company users - $zektoSettings = []; - $zektoSettings = [ - 'zkteco_api_url' => isset($systemSettings['zkteco_api_url']) ? $systemSettings['zkteco_api_url'] : '', - 'zkteco_username' => isset($systemSettings['zkteco_username']) ? $systemSettings['zkteco_username'] : '', - 'zkteco_password' => isset($systemSettings['zkteco_password']) ? $systemSettings['zkteco_password'] : '', - 'zkteco_auth_token' => isset($systemSettings['zkteco_auth_token']) ? $systemSettings['zkteco_auth_token'] : '', - ]; - // Get NOC templates for company users $nocTemplates = NocTemplate::where('created_by', Auth::user()->id)->get(); @@ -58,7 +49,7 @@ class SettingsController extends Controller 'timeFormats' => config('timeformat'), 'paymentSettings' => $paymentSettings, 'webhooks' => $webhooks, - 'zektoSettings' => $zektoSettings, + 'ipRestrictions' => $ipRestrictions, 'nocTemplates' => $nocTemplates, 'joiningLetterTemplates' => $joiningLetterTemplates, diff --git a/app/Http/Controllers/ZektoSettingsController.php b/app/Http/Controllers/ZektoSettingsController.php deleted file mode 100644 index 4d3a1f84d..000000000 --- a/app/Http/Controllers/ZektoSettingsController.php +++ /dev/null @@ -1,96 +0,0 @@ -can('manage-biomatric-attedance-settings')) { - $validator = Validator::make($request->all(), [ - 'zkteco_api_url' => 'required|url', - 'zkteco_username' => 'required|string|max:255', - 'zkteco_password' => 'required|string|max:255', - ]); - - if ($validator->fails()) { - return redirect()->back()->withErrors($validator)->withInput(); - } - - // Update settings - updateSetting('zkteco_api_url', $request->zkteco_api_url); - updateSetting('zkteco_username', $request->zkteco_username); - updateSetting('zkteco_password', $request->zkteco_password); - updateSetting('isZktecoSync', 0); - - return redirect()->back()->with('success', __('ZKTeco settings updated successfully')); - } else { - return redirect()->back()->with('error', __('Permission Denied.')); - } - } - - /** - * Generate auth token from ZKTeco API - */ - public function generateToken(Request $request) - { - if (Auth::user()->can('manage-biomatric-attedance-settings')) { - $validator = Validator::make($request->all(), [ - 'zkteco_api_url' => 'required|url', - 'zkteco_username' => 'required|string', - 'zkteco_password' => 'required|string', - ]); - - if ($validator->fails()) { - return redirect()->back()->withErrors($validator)->withInput(); - } - - try { - $url = "$request->zkteco_api_url" . '/api-token-auth/'; - $headers = array( - "Content-Type: application/json" - ); - $data = array( - "username" => $request->zkteco_username, - "password" => $request->zkteco_password - ); - - $ch = curl_init(); - curl_setopt($ch, CURLOPT_URL, $url); - curl_setopt($ch, CURLOPT_POST, true); - curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data)); - curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); - curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); - - $response = curl_exec($ch); - curl_close($ch); - $auth_token = json_decode($response, true); - if (isset($auth_token['token'])) { - // Store the generated token using existing settings structure - updateSetting('isZktecoSync', 1); - updateSetting('zkteco_auth_token', $auth_token['token']); - - return redirect()->back()->with([ - 'success' => __('Auth token generated successfully'), - 'token' => $auth_token['token'] - ]); - } else { - throw new \Exception(isset($auth_token['non_field_errors']) ? $auth_token['non_field_errors'][0] : "Something went wrong please try again"); - } - } catch (\Exception $e) { - - return redirect()->back()->with('error', $e->getMessage()); - } - } else { - return redirect()->back()->with('error', __('Permission Denied.')); - } - } -} diff --git a/app/Models/Branch.php b/app/Models/Branch.php index 70e3efbef..3a01b9462 100644 --- a/app/Models/Branch.php +++ b/app/Models/Branch.php @@ -11,6 +11,7 @@ class Branch extends BaseModel protected $fillable = [ 'name', + 'zkteco_ip', 'address', 'city', 'state', diff --git a/database/migrations/2026_05_19_025935_add_zkteco_ip_to_branches_table.php b/database/migrations/2026_05_19_025935_add_zkteco_ip_to_branches_table.php new file mode 100644 index 000000000..b82d7d5f7 --- /dev/null +++ b/database/migrations/2026_05_19_025935_add_zkteco_ip_to_branches_table.php @@ -0,0 +1,28 @@ +string('zkteco_ip')->nullable()->after('name'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('branches', function (Blueprint $table) { + $table->dropColumn('zkteco_ip'); + }); + } +}; diff --git a/resources/js/pages/hr/biometric-attendance/index.tsx b/resources/js/pages/hr/biometric-attendance/index.tsx index a467bc903..5df11c4d0 100755 --- a/resources/js/pages/hr/biometric-attendance/index.tsx +++ b/resources/js/pages/hr/biometric-attendance/index.tsx @@ -269,11 +269,35 @@ export default function BiometricAttendance() { {/* Content section */}