feat: implement multi-branch biometric IP configuration and auto-sync

This commit is contained in:
2026-05-19 11:24:41 +08:00
parent 2ecce8a990
commit 20a79a130d
12 changed files with 337 additions and 435 deletions

View File

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