<!--
	covers: commands/GateSyncController.php models/DoorOpeningPhoneNumber.php models/GateSyncQueue.php services/AbstractGateService.php services/GateServiceInterface.php services/MockGateService.php services/TellApiLog.php services/TellGateService.php
	verified: 54fd0a6
-->
# Gate control sync (TELL)

Door-opening phone numbers are pushed to TELL Gate Control PRO devices through a queue. A phone number that
can open a gate is derived from the rental contract, so the physical access and the ERP never drift apart —
except when they do, which is what `reconcile` is for.

## The pieces

| Piece | Role |
|-------|------|
| [`Location`](../../models/Location.php) | carries the device: `gateEnabled`, `gateHwId`, `gateAppId`, `gateSchemeId` |
| [`DoorOpeningPhoneNumber`](../../models/DoorOpeningPhoneNumber.php) | `rentId`, `phone`, `gateUserId` — a phone allowed to open the gate of that rental's location |
| [`GateSyncQueue`](../../models/GateSyncQueue.php) | the pending device operations |
| [`GateServiceInterface`](../../services/GateServiceInterface.php) | the device contract |
| [`TellGateService`](../../services/TellGateService.php) / [`MockGateService`](../../services/MockGateService.php) | real HTTP client / dev stub |
| [`TellApiLog`](../../services/TellApiLog.php) | `runtime/logs/tell-api.log` |
| [`GateSyncController`](../../commands/GateSyncController.php) | `auto`, `reconcile`, `register`, `dump` |

**The device lives on the location, not the storage.** One gate per site; every rental at that site adds its
phones to the same device.

`gateUserId` is the **device-side uid**, not ours. It is written back after a successful add.

## Configuration

| Constant | Meaning |
|----------|---------|
| `APP_GATE_SYNC_ENABLED` | master switch — everything below is a no-op without it |
| `APP_GATE_SYNC_FAKE` | use `MockGateService`: no HTTP, no API key needed |
| `TELL_API_URL`, `TELL_API_KEY` | account-level HTTPS API key, sent as the `api-key` header |
| `TELL_API_TIMEOUT` | request timeout |
| `TELL_API_INSERTER` | registration / inserter name, **max 10 characters** |
| `TELL_API_LOG_MODE` | `off` \| `all` \| `error` |

## Queueing

`GateSyncQueue::enqueueAdd/Edit/Remove( $phone )` plus `enqueueRemoveAt( $location, $phone )` for the case
where a rental moved to another storage and the phone has to be removed from the **previous** location's
device.

All four funnel into `enqueueFor()`
([`models/GateSyncQueue.php:130`](../../models/GateSyncQueue.php)), which applies four guards before writing
anything:

1. `APP_GATE_SYNC_ENABLED` must be on;
2. the location must resolve (`$phone->rent?->storage?->location` unless given explicitly) and be
   `gateEnabled` with a `gateHwId`;
3. a `REMOVE` for a phone with no `gateUserId` is dropped — it was never on the device;
4. **dedup**: an identical pending job (same action + location + phone record + phone number) means return.

The job is then saved with `save( false )` — **no validation**.

`name` is set to `'r24-' . $phone->id` and `fname` to the customer's display name truncated to 40 characters.
That `r24-` prefix is load-bearing: it is how `reconcile` tells our users apart from ones an operator added by
hand on the device keypad.

## Draining — `./yii gate-sync/auto`

Runs every minute. Structure:

```php
if( !$this->ready() ) return APP_GATE_SYNC_ENABLED ? ExitCode::CONFIG : ExitCode::OK;
if( !Yii::$app->mutex->acquire( self::MUTEX ) ) return ExitCode::OK;
try {
    $deadline = time() + 50;
    $failed = [];
    while( time() < $deadline ) {
        $job = GateSyncQueue::find()
            ->andWhere( [ '<', 'attempts', GateSyncQueue::MAX_ATTEMPTS ] )
            ->andWhere( $failed ? [ 'not in', 'id', $failed ] : [] )
            ->orderBy( [ 'id' => SORT_ASC ] )->one();
        if( !$job ) break;
        try { $this->processJob( $job, $service ); $job->delete(); }
        catch( Throwable $e ) { $job->attempts++; $job->error = $e->getMessage(); $job->save( false ); $failed[] = $job->id; }
    }
} finally { Yii::$app->mutex->release( self::MUTEX ); }
```

Four things to note:

- **Time-boxed to 50 seconds**, so a one-minute cron never overlaps itself even before the mutex.
- **`MysqlMutex` named `gate-sync`**, shared with `reconcile` — the two can never run together.
- **Failures are retried up to `MAX_ATTEMPTS = 10`**, then left in the table. A job at 10 attempts is
  invisible to the drain loop and surfaces on the dashboard as the `failedGateSync` error notification, which
  renders the action, phone and truncated error.
- **`$failed` is per-run**, so a job that just failed is skipped for the rest of this invocation rather than
  retried in a tight loop.

Exit codes distinguish the two "did nothing" cases: `CONFIG` when the sync is enabled but unusable (no API
key, no mock), `OK` when it is simply switched off.

## Reconciling — `./yii gate-sync/reconcile`

Every 15 minutes. For each gate-enabled location it compares the phones of the **active rentals** against the
device's user list and fixes the difference.

**Only users whose name starts with `r24-` are touched.** Anything an operator added directly on the device is
left alone.

This is the safety net for: a failed job that exhausted its attempts, a device reset, a manual change, or a
row edited straight in the database.

## Manual operations

| Command | Use |
|---------|-----|
| `./yii gate-sync/register <locationId>` | performs the superadmin registration handshake and stores the returned `appId` on the location |
| `./yii gate-sync/dump <locationId>` | prints the device's current user list — the first thing to run when something looks wrong |

Registration is the one operation that cannot be replayed: `registrationRequest()` returns an `appId` that
must be persisted, and requesting a second one does not invalidate the first.

## The device abstraction

`$device` is a plain associative array — `hwId`, `appId`, `hwName`, `schemeId` — assembled from the
`Location` at process time, **not** stored on the job. So a job queued before a device was reconfigured picks
up the new coordinates when it runs.

```php
getUsers( $device, $nameFilter ) : array    // uid, name, fname, phnr
addUser( $device, $phone, $name, $fname ) : void
editUser( $device, $uid, $phone, $name, $fname ) : void
deleteUser( $device, $uid ) : void          // a missing user counts as success
findUid( $device, $phone ) : ?string
registrationRequest( $device, $name, $phone ) : array
```

`deleteUser()` treating a missing user as success is what makes the whole pipeline idempotent — a retried
`REMOVE` after a partially successful run does not fail forever.

## Local development

Set `APP_GATE_SYNC_ENABLED = true` and `APP_GATE_SYNC_FAKE = true`. `MockGateService` satisfies the interface
without HTTP or an API key, so the queue, the retry logic, the dashboard notification and `reconcile` are all
exercisable offline.

## Traps

1. **Everything is a silent no-op when `APP_GATE_SYNC_ENABLED` is false** — including `enqueueFor()`. Turning
   the flag on later does **not** backfill the phones added while it was off; run `reconcile`.
2. **Jobs are saved with `save( false )`.** A malformed job is persisted and fails at process time instead.
3. **`MAX_ATTEMPTS` exhausted means the job stops being retried entirely** and lives in the table forever
   until someone resets `attempts` or deletes it.
4. **The `r24-` name prefix is the ownership marker.** Changing it orphans every existing device user from
   `reconcile`'s point of view.
5. **`fname` is truncated to 40 characters** and `TELL_API_INSERTER` to 10 — device limits, not ours.
6. **A location losing `gateEnabled` stops new jobs but does not remove existing device users.**
