Files
HRM-System/app/Http/Controllers/Api/AttendanceSyncController.php

131 lines
4.9 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use Illuminate\Http\Request;
use App\Models\Branch;
use App\Models\AttendanceEmployee;
use Illuminate\Support\Facades\Log;
class AttendanceSyncController extends Controller
{
public function sync(Request $request)
{
// 1. Verify API Key
$apiKey = $request->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;
}
}
}