# ZKTeco ADMS — Biometric Logs Mirror

Laravel 12 + Filament 4 application that mirrors biometric attendance logs from a ZKTeco cloud API to a local MySQL database via real-time webhooks and async job queuing.

**Live URL:** https://adms.elmntointernet.com  
**Admin Panel:** https://adms.elmntointernet.com/admin

---

## Requirements

- PHP 8.4+
- MySQL 8.0+ (managed DigitalOcean DB in production)
- Composer
- Node.js + npm (for Vite assets)
- Queue worker (database or Redis)

---

## Local Server Deployment

### 1. Clone and install dependencies

```bash
cd /var/www/html/adms
composer install --no-dev --optimize-autoloader
npm install && npm run build
```

### 2. Configure environment

Copy the example env and fill in values:

```bash
cp .env.example .env
php artisan key:generate
```

Required `.env` values:

```env
APP_NAME="ZKTeco ADMS"
APP_ENV=production
APP_URL=https://adms.elmntointernet.com

DB_CONNECTION=mysql
DB_HOST=<your-db-host>
DB_PORT=3306
DB_DATABASE=adms
DB_USERNAME=<user>
DB_PASSWORD=<password>

QUEUE_CONNECTION=database

# Biometric cloud sync
BIOMETRIC_SYNC_ENABLED=true
BIOMETRIC_API_ENDPOINT=https://your-cloud-api.com
BIOMETRIC_CLOUD_API_KEY=your_api_key_here
BIOMETRIC_WEBHOOK_SECRET=your_webhook_secret_here
```

### 3. Run migrations

```bash
php artisan migrate --force
```

This runs all migrations including the cloud sync columns added to `zkteco_attendance_logs`.

### 4. Start the queue worker

The webhook handler dispatches a background job — the queue worker must be running:

```bash
# Development
php artisan queue:work

# Production (via Supervisor — see supervisor config below)
```

### 5. Verify routes are registered

```bash
php artisan route:list | grep webhook
```

Expected output:
```
POST  api/webhooks/biometric-logs         (main webhook)
GET   api/webhooks/biometric-logs/health  (health check)
POST  api/webhooks/biometric-logs/retry   (retry failed, auth required)
```

> **Note:** `bootstrap/app.php` must include `api: __DIR__.'/../routes/api.php'` in `withRouting()`. This is already set.

### 6. Test the health endpoint

```bash
curl https://adms.elmntointernet.com/api/webhooks/biometric-logs/health
```

Expected response:
```json
{"status":"healthy","timestamp":"...","pending_logs":0,"failed_logs":0}
```

---

## Webhook Integration

The cloud API must send `POST /api/webhooks/biometric-logs` with this payload and headers:

### Headers

```
Content-Type: application/json
X-Webhook-Signature: <HMAC-SHA256 of raw JSON body using BIOMETRIC_WEBHOOK_SECRET>
X-Webhook-Timestamp: <ISO-8601 datetime, e.g. 2026-06-09T14:30:00Z>
```

### Payload

```json
{
  "log_id": "cloud_uuid_or_id_12345",
  "device_id": 1,
  "pin": "111111",
  "punched_at": "2026-06-09 14:30:00",
  "status": 0,
  "verify_type": 1,
  "work_code": null
}
```

### Field Reference

| Field | Type | Required | Values |
|-------|------|----------|--------|
| `log_id` | string | Yes | Unique cloud log ID (for idempotency) |
| `device_id` | integer | Yes | Device ID in local DB |
| `pin` | string | Yes | Employee PIN (not user_id) |
| `punched_at` | string | Yes | `Y-m-d H:i:s` format |
| `status` | integer | Yes | 0=Check In, 1=Check Out, 2=Break Out, 3=Break In, 4=OT In, 5=OT Out |
| `verify_type` | integer | Yes | 0=Password, 1=Fingerprint, 2=Card, 15=Face |
| `work_code` | integer | No | Optional |

### Signature calculation (PHP)

```php
$payload   = json_encode($data);
$secret    = env('BIOMETRIC_WEBHOOK_SECRET');
$timestamp = date('c'); // ISO 8601

$signature = hash_hmac('sha256', $payload, $secret);
```

---

## Key Files

| File | Purpose |
|------|---------|
| `app/Models/AttendanceLog.php` | Extends package model with cloud sync scopes and helpers |
| `app/Http/Controllers/Api/BiometricLogWebhookController.php` | Webhook receiver (signature verify, validate, queue dispatch) |
| `app/Jobs/ProcessBiometricLogSync.php` | Async job: validates device/user, marks synced or failed |
| `app/Services/BiometricLogSyncService.php` | Business logic: pull, push, cleanup, statistics |
| `app/Events/BiometricLogSynced.php` | Event fired after successful sync |
| `app/Console/Commands/SyncBiometricLogs.php` | CLI commands for manual operations |
| `config/biometric-cloud.php` | All biometric config (reads from .env) |
| `routes/api.php` | Webhook API routes |
| `bootstrap/app.php` | App bootstrap — registers api route file |
| `database/migrations/2026_06_09_120000_add_cloud_sync_to_attendance_logs.php` | Adds cloud sync columns to `zkteco_attendance_logs` |

---

## Database

Logs are stored in `zkteco_attendance_logs`. The migration adds these tracking columns:

| Column | Type | Description |
|--------|------|-------------|
| `cloud_log_id` | varchar, unique | Cloud API log ID (idempotency key) |
| `cloud_sync_time` | timestamp | When the log was synced from cloud |
| `sync_status` | enum | `pending`, `success`, `failed` |
| `retry_count` | int | Number of processing attempts |
| `error_message` | text | Last error if sync failed |

---

## CLI Commands

```bash
# Check sync statistics
php artisan biometric:sync

# Pull logs from cloud API
php artisan biometric:sync --pull

# Retry failed logs
php artisan biometric:sync --retry

# Clean up logs older than configured retention days
php artisan biometric:sync --cleanup

# Run all operations
php artisan biometric:sync --all
```

---

## Querying Logs (Tinker)

```bash
php artisan tinker
```

```php
// Counts
App\Models\AttendanceLog::count();
App\Models\AttendanceLog::pending()->count();
App\Models\AttendanceLog::success()->count();
App\Models\AttendanceLog::failed()->count();

// Today's logs
App\Models\AttendanceLog::today()->get();

// Logs for a specific employee
App\Models\AttendanceLog::where('pin', '111111')->get();

// Date range
App\Models\AttendanceLog::dateRange('2026-06-01', '2026-06-09')->get();

// Failed logs due for retry
App\Models\AttendanceLog::dueForRetry(3)->get();

// Failed queue jobs
DB::table('failed_jobs')->get();
```

---

## Production: Supervisor Config

Create `/etc/supervisor/conf.d/adms-worker.conf`:

```ini
[program:adms-worker]
process_name=%(program_name)s_%(process_num)02d
command=php /var/www/html/adms/artisan queue:work --sleep=3 --tries=3 --max-time=3600
autostart=true
autorestart=true
stopasgroup=true
killasgroup=true
user=www-data
numprocs=2
redirect_stderr=true
stdout_logfile=/var/www/html/adms/storage/logs/worker.log
stopwaitsecs=3600
```

```bash
sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start adms-worker:*
```

---

## Monitoring & Troubleshooting

```bash
# Monitor queue worker output
php artisan queue:work --verbose

# Tail application logs (biometric events)
tail -f storage/logs/laravel.log | grep biometric

# List failed jobs
php artisan queue:failed

# Retry all failed jobs
php artisan queue:retry all

# Health endpoint
curl https://adms.elmntointernet.com/api/webhooks/biometric-logs/health
```

### Common issues

| Problem | Fix |
|---------|-----|
| `401 Invalid signature` | `BIOMETRIC_WEBHOOK_SECRET` must match what the cloud API uses |
| Webhook returns 404 | Check `bootstrap/app.php` has `api:` in `withRouting()` |
| Logs stuck in pending | Queue worker not running — `ps aux \| grep queue:work` |
| `401` on retry endpoint | Requires Sanctum Bearer token |
| Timestamp rejected | Cloud API timestamp must be within 10 minutes of server time |

---

## Deployment Checklist

- [ ] `.env` has all `BIOMETRIC_*` vars set
- [ ] `php artisan migrate --force` completed
- [ ] `php artisan route:list | grep webhook` shows 3 routes
- [ ] Health endpoint returns `{"status":"healthy",...}`
- [ ] Queue worker running (`ps aux | grep queue:work`)
- [ ] Supervisor configured and started
- [ ] Cloud API webhook URL set to `https://adms.elmntointernet.com/api/webhooks/biometric-logs`
- [ ] Cloud API shared secret matches `BIOMETRIC_WEBHOOK_SECRET`
- [ ] `storage/` directory is writable
