header('X-API-Key') ?? $request->input('api_key'); // You should store a master API key in your .env like ZKTECO_SYNC_KEY=xxx $validKey = env('ZKTECO_SYNC_KEY', 'default_secure_sync_key_123'); if ($apiKey !== $validKey) { Log::warning('Unauthorized ZKTeco sync attempt', ['ip' => $request->ip()]); return response()->json(['error' => 'Unauthorized'], 401); } $branchId = $request->input('branch_id'); $zktecoIp = $request->input('zkteco_ip'); $attendances = $request->input('attendances', []); if (empty($attendances)) { return response()->json(['message' => 'No attendance records provided'], 400); } // 2. Find the branch $branchQuery = Branch::query(); if ($branchId) { $branchQuery->where('id', $branchId); } elseif ($zktecoIp) { $branchQuery->where('zkteco_ip', $zktecoIp); } else { return response()->json(['error' => 'Must provide branch_id or zkteco_ip'], 400); } $branch = $branchQuery->first(); if (!$branch) { return response()->json(['error' => 'Branch not found'], 404); } // 3. Process the logs into a standardized format for the dashboard $formattedAttendances = []; foreach ($attendances as $record) { $empCode = $record['deviceUserId'] ?? $record['id'] ?? null; $rawPunchTime = $record['recordTime'] ?? $record['timestamp'] ?? null; $punchTime = $this->parseDateSafe($rawPunchTime); $uid = $record['userSn'] ?? $record['uid'] ?? null; $type = $record['state'] ?? $record['type'] ?? 0; if ($empCode && $punchTime) { // We format it identically to how ZKTeco hardware outputs `$raw_attendance` // so BiometricAttendanceController can process it natively. $formattedAttendances[] = [ 'uid' => $uid, 'id' => (string) $empCode, 'timestamp' => $punchTime->toDateTimeString(), // Clean Y-m-d H:i:s format 'type' => $type ]; } } // Save the formatted records to a JSON file cache for the branch if (!empty($formattedAttendances)) { $filePath = storage_path("app/biometric_cache_branch_{$branch->id}.json"); // If the file exists, we merge the new records with the old ones, keeping only unique punches if (file_exists($filePath)) { $existing = json_decode(file_get_contents($filePath), true) ?? []; $merged = array_merge($existing, $formattedAttendances); // Remove exact duplicates by using a unique key $unique = collect($merged)->unique(function ($item) { return $item['id'] . '_' . $item['timestamp']; })->values()->toArray(); // Keep only the last 3000 records to prevent file bloat if (count($unique) > 3000) { $unique = array_slice($unique, -3000); } file_put_contents($filePath, json_encode($unique)); } else { file_put_contents($filePath, json_encode($formattedAttendances)); } } $processed = count($formattedAttendances); return response()->json([ 'success' => true, 'message' => "Successfully pushed {$processed} records to the Biometric Attendance waiting room for Branch {$branch->id}.", ]); } /** * Safely parse various date formats coming from ZKTeco devices or JS agents. */ private function parseDateSafe($dateStr) { if (!$dateStr) { return null; } // Handle numeric timestamps (Unix seconds or milliseconds) if (is_numeric($dateStr)) { if (strlen((string)$dateStr) > 10) { // It's likely in milliseconds return \Carbon\Carbon::createFromTimestampMs($dateStr); } return \Carbon\Carbon::createFromTimestamp($dateStr); } // Remove JS-specific timezone names like "(China Standard Time)" to prevent Carbon parse errors $cleanDateStr = trim(explode('(', $dateStr)[0]); try { return \Carbon\Carbon::parse($cleanDateStr); } catch (\Exception $e) { return null; } } }