ADMS (Attendance Device Management System) is an edge server that sits on the local network with your ZKTeco devices. It does two jobs:
It also auto-enrolls employees onto the devices (pushing each person's ID, name, and RFID card down to the hardware) so nobody types employees in by hand. It replaces the older tcd-local-server.
Three parts. The devices talk only to ADMS (LAN); ADMS talks to DMPI (internet).
ZKTeco devices ADMS (this app) DMPI payroll
(RFID, IN/OUT) Laravel + MySQL (Django + Postgres)
────────────── ─────────────── ────────────────
│ punch (LAN) │ │
│ ───────────────────────▶ store + map │
│ /iclock/cdata │ ──── push punch ───────────────▶ /api/sync-logs/
│ │ │
│ poll for commands │ ◀──── pull roster/devices ───── /api/v2/read_*
│ ◀─────────────────────── enroll users │
│ /iclock/getrequest │ │
270_39475) + timestamp.attendances (status pending) and freezes its IN/OUT from the device's direction at that moment./api/sync-logs/.DATA UPDATE USERINFO command; the device polls, receives it, and creates the user with their card.Direction matters: a punch's in/out is set from the device's direction (or, for a "both" device, from the tap's own state code) at the moment the tap is received, then frozen onto the record. DMPI uses it to pair clock-ins with clock-outs. Changing a device's direction later only affects future taps — it never rewrites past punches, and IN/OUT is read-only on the Attendance screen.
| Device PIN | What the device knows an employee by, and what every tap carries. Format is {company}_{chapa} (e.g. 270_39475). The company prefix is required because CHAPA numbers repeat across the manpower companies — the composite makes each one globally unique. |
|---|---|
| CHAPA No. | The employee's badge number in DMPI. |
| Payroll ID | DMPI's internal Employee.id — the value ADMS actually sends to payroll. The device never knows this; ADMS translates the PIN to it. |
| Employee map | The translation table in ADMS: device PIN → payroll id (+ name, company, RFID). Built from the roster pull. |
| Direction | Each device is IN, OUT, or BOTH. It sets the in/out label frozen onto each punch as it arrives. A BOTH device reads in/out from the tap's own state code. Editing a device's direction only changes future punches; existing ones keep what they were stamped with. |
| RFID / Card | The physical card number, assigned to the employee in DMPI. ADMS pushes it to the device exactly as DMPI stores it (no conversion). |
| sync_id | Idempotency key per punch ({serial}-{id}). Prevents the same punch being double-counted in payroll. |
| Monitoring | The single "is everything OK?" page (the old Dashboard and Health are now merged here, and both redirect to it). Shows an overall status banner, At a glance stat cards (devices online/offline, punches today, pending/failed sync, unmapped PINs), and a System health grid of checks (database, scheduler, payroll credentials, DMPI reachable, sync backlog, roster, devices, recent errors). Each health card is clickable to the underlying data. Auto-refreshes every 30s and has the Start scheduler button. |
|---|---|
| Devices | Each device's online status, name/location, IN/OUT direction, and which DMPI device it's linked to. Set direction + link here, and use Sync enrollments to push users to a device. |
| Employees | Two tabs: Mapped — the roster (name, company, CHAPA, device PIN, RFID card, payroll id, and the physical device serial(s) each person is enrolled on); searchable across every column and filterable by enrolled device. Unmapped PINs — people tapping who aren't matched to an employee yet. The Sync from DMPI button lives here. |
| Attendance | Every punch with employee details, device + location, and its full lifecycle as three time columns — Punched (at the device), Received (by ADMS), Synced (to payroll) — plus a sync-status badge. IN/OUT is shown read-only. Filter by date/device/company/employee/sync. Sync to payroll now pushes pending punches on demand. |
| Logs ▾ → Server Activity | What ADMS itself is doing (sync runs, pulls, reconciles, errors). See section 8. |
| Logs ▾ → Device Check-ins | When each device connected and reported its settings — useful to confirm a device is reaching the server. (Was "Device Log".) Auto-pruned after 30 days. |
| Logs ▾ → Device Messages | Everything a device sends — attendance taps and on-device activity (menu/settings) — with a plain-language "What happened" column and the raw payload tucked into "Technical details". (Was "Finger Log".) Auto-pruned after 30 days. |
| Button | Where | What it does |
|---|---|---|
| Sync from DMPI | Employees | Pulls from DMPI on demand: the employee roster (names + RFID), the device list + assignments, then re-queues enrollment commands. Use it after you change something in DMPI (e.g. add an employee, set an RFID, assign someone to a device) and want it reflected now instead of waiting for the hourly sync. Inbound (DMPI → ADMS). |
| Sync to payroll now | Attendance | Pushes all pending punches to DMPI immediately, instead of waiting for the every-minute job. Outbound (ADMS → DMPI). Safe to click anytime; already-synced punches are skipped. |
| Sync enrollments | Devices (per device) | Queues the DATA UPDATE/DELETE USERINFO commands to make that physical reader's user list match its DMPI assignments. The device applies them on its next check-in. Use after linking a device or changing who's assigned to it. |
| Start scheduler | Monitoring | (Re)starts the background scheduler if it has stopped — e.g. after a server/container restart. The scheduler is what makes the automatic every-minute/hourly syncs run. If the Scheduler health card is red, click this. No terminal needed; it won't start a duplicate if one's already running. |
Sync from DMPI = pull data in; Sync to payroll now = push punches out; Sync enrollments = push users down to a reader. Each also has an equivalent artisan command (section 10), and all run automatically on a schedule.
PAYROLL_URL, PAYROLL_USERNAME, PAYROLL_PASSWORD in .env (a timekeeper-access service account). User-Agent must be YP_TIMEKEEPER.php artisan payroll:sync-roster + payroll:sync-devices, or let the scheduler do it). This fills Employees + the device dropdown.php artisan schedule:work, or the Start scheduler button on Monitoring). If the Scheduler health card ever goes red, click Start scheduler.| Online status | A device shows online if it contacted ADMS within 5 minutes; otherwise offline. The Devices page refreshes this every 60s. |
|---|---|
| Push cadence | Pending punches are pushed every minute. "0 synced, 0 failed" simply means nothing was waiting. |
| Enrollment reconcile | Runs every 15 minutes; queues device commands only when assignments change. |
| Roster / device pull | Runs hourly. Pulls the full roster + assignments from DMPI. |
| IN/OUT is frozen | A punch's IN/OUT is locked in when it's received, from the device's direction at that moment. It's read-only afterward; changing a device's direction only affects future taps. |
| Day attribution | ADMS sends the raw timestamp; DMPI decides which day a punch belongs to (it may re-date a night-shift OUT to the previous day). A synced punch may appear on a different date in DMPI than the tap date — that's expected. |
| Duplicates | Re-sent device records are de-duplicated; DMPI also ignores duplicate sync_ids. |
| Offline resilience | If a device loses network, it buffers punches and sends them when it reconnects. If ADMS loses internet, punches queue locally and push when it returns. |
| Log retention | Raw device logs are pruned after 30 days. Attendance records are kept. |
The Server Activity page logs each scheduled job. Levels: info normal, warning some failures, error the job itself failed.
| attendance.sync | The minute push. "X synced, Y failed". 0/0 = nothing was pending (healthy idle). |
| enrollment.reconcile | The 15-min enrollment check. "Commands queued: N" — 0 means devices already match payroll. |
| devices.sync | The hourly device + assignment pull. "Devices: 89, assignments: 139047" = how much was pulled. |
| roster.sync | The hourly employee pull. If it shows an error ("timed out"), that's the known DMPI read_employees slowness (see troubleshooting). |
| dmpi.pull | A manual Sync from DMPI button press — pulled roster + devices and reconciled enrollments on demand. |
| scheduler.start | The Start scheduler button was used to (re)start the background scheduler. |
| Symptom | Likely cause & fix |
|---|---|
| Punch shows unmapped | The tapped PIN isn't in the employee map. Either the roster hasn't loaded, or the device was enrolled with the wrong User ID. The User ID on the device must equal the composite {company}_{chapa}. Check the Employees → Unmapped device PINs list. |
| Punch stuck pending | Nothing has pushed it. Confirm the scheduler is running (Monitoring → Scheduler card should be green; click Start scheduler if not) or click Sync to payroll now on Attendance. Also check payroll credentials are set in .env. |
| Scheduler health card is fail | The background scheduler stopped (common after a server/container restart, since it isn't auto-started). Click Start scheduler on the Monitoring page — automatic syncing resumes within a minute. For a permanent fix, run it under supervisor/cron so it restarts on boot. |
| Punch shows failed | Hover the badge for the reason. "No Employee" = the payroll id doesn't exist / wrong mapping. "No schedule" = the employee has no work schedule in DMPI for that day (a DMPI data fix). |
| Synced, but not in DMPI's Daily Logs | DMPI re-dated it (night-shift / schedule). Look at the day before/after, or check the punch's group_date in DMPI. The sync itself is fine if Attendance shows green. |
| Device shows offline | No contact in 5+ minutes. Check the device's power/network and that its server setting points to ADMS's LAN IP. Buffered punches flush on reconnect. |
| Employees / roster won't populate | The DMPI read_employees endpoint is slow/hangs at scale (it returns the whole cluster). Watch Server Activity for a roster.sync error. Fix is on the DMPI side (optimize / scope that endpoint). Until then, the roster can be seeded manually. |
| Enrollment didn't take on the device | The device may reject the User ID format (underscore) or card format. Confirm on the device's user list; check Server Activity for the reconcile, and the device's command result. Tap the card — if a punch arrives with the right PIN, enrollment worked. |
| How to debug anything | 1) Server Activity page (what ran + errors). 2) The Attendance sync badge + error reason. 3) Run a command by hand to see output (php artisan payroll:sync-attendances). 4) Laravel logs: storage/logs/laravel.log. |
All commands are run with artisan, from the project root (the folder containing the artisan file). They are server-side commands — you run them in a terminal on the ADMS host (or its container), not in the browser.
cd /path/to/adms-server ./vendor/bin/sail artisan payroll:sync-attendances
cd /var/www/adms php artisan payroll:sync-attendances
docker compose exec web php artisan payroll:sync-attendances
Tip: every UI button maps to a command here — Sync from DMPI = sync-roster + sync-devices + reconcile-enrollments; Sync to payroll now = sync-attendances; Sync enrollments = reconcile-enrollments; Start scheduler = schedule:work. Useful for testing or one-off runs; in normal operation the scheduler runs them for you (see Deployment).
payroll:sync-attendances | Push pending punches to DMPI (auto every minute). |
payroll:sync-roster | Pull the employee roster (incl. RFID) into the map (auto hourly). |
payroll:sync-devices | Pull the device list + assignments from DMPI (auto hourly). |
payroll:reconcile-enrollments | Queue device commands to match users to assignments (auto every 15 min). |
logs:prune | Delete raw device logs older than the retention window (auto daily). |
schedule:work | Run the scheduler so all the above fire automatically. |
.env settingsPAYROLL_URL | DMPI base URL. |
PAYROLL_USERNAME / PAYROLL_PASSWORD | The timekeeper service account. |
PAYROLL_USER_AGENT | YP_TIMEKEEPER (DMPI grants access by user-agent). |
PAYROLL_TIMEOUT | Seconds to wait on DMPI's slow read endpoints (default 600). |
PAYROLL_BATCH_SIZE | How many punches to push per batch. |
ADMS_LOG_RETENTION_DAYS | Days of raw device logs to keep (default 30). |
ADMS runs as one instance per site, on a machine on the same LAN as that site's devices. It must be reachable by the devices (port 80) and able to reach DMPI over the internet.
git clone <repo> adms-server && cd adms-server cp .env.example .env # then edit: APP_URL, DB_*, PAYROLL_* ./vendor/bin/sail up -d --build # starts app + MySQL ./vendor/bin/sail artisan key:generate ./vendor/bin/sail artisan migrate --force
Run the scheduler (this is what makes sync run automatically). Either add a dedicated long-running process:
./vendor/bin/sail artisan schedule:work # keep running (e.g. via a compose service / supervisor)
git clone <repo> /var/www/adms && cd /var/www/adms composer install --no-dev --optimize-autoloader cp .env.example .env # edit APP_URL, DB_*, PAYROLL_* php artisan key:generate php artisan migrate --force php artisan config:cache && php artisan route:cache
Point your web server's document root at public/ and serve on port 80 so devices can reach /iclock/*.
Add the scheduler to cron (the standard Laravel one-liner) so all jobs fire on time:
* * * * * cd /var/www/adms && php artisan schedule:run >> /dev/null 2>&1
On each device's Cloud/ADMS server setting, set the server to ADMS's LAN IP and port 80. It will auto-register on the Devices page; then set its direction + payroll link and Sync enrollments.
git pull composer install --no-dev --optimize-autoloader # (sail: ./vendor/bin/sail composer install ...) php artisan migrate --force php artisan config:cache route:cache # restart php-fpm / the container
schedule:work) — without it, punches won't sync automatically (only via the manual button/command)./iclock/*) are intentionally open + CSRF-exempt so devices can post.