# Disconnect Vehicles from LinkByCar — Admin API

Remove vehicles from the LinkByCar telematics platform. De-registering a VIN stops data
collection and the per-vehicle subscription that goes with it, then clears the local link.

Three endpoints: one vehicle (synchronous), one company (queued), and a read used both to
confirm the company action and to watch it drain.

Counterpart of [`api-connect-vehicle-linkbycar.md`](./api-connect-vehicle-linkbycar.md).

---

## Authentication

- **Method:** Bearer token
- **Permission:** `admin_vehicles.manage` (the same one guarding the rest of `/api/admin/vehicles`)
- `401` when the token is missing or invalid, `403` without the permission.

---

## 1. Disconnect one vehicle

```
POST /api/admin/vehicles/{vehicle_id}/disconnect-linkbycar
```

| Parameter    | Type    | Description           |
|--------------|---------|-----------------------|
| `vehicle_id` | integer | The ID of the vehicle |

No request body.

Archived (soft-deleted) vehicles are accepted: a vehicle out of the fleet that is still
registered with LinkByCar is still being collected and billed, and is a main reason to use
this endpoint.

### 200 OK

```json
{
  "status": true,
  "message": "Véhicule déconnecté de LinkByCar avec succès.",
  "data": { "vehicle_id": 42, "outcome": "disconnected" }
}
```

`outcome` is the only thing the FE needs to branch on:

| `outcome`        | Meaning                                                                                 | Local state after |
|------------------|-----------------------------------------------------------------------------------------|-------------------|
| `disconnected`   | LinkByCar removed the vehicle.                                                            | `connection_status = 'E'` (Eligible) |
| `not_registered` | LinkByCar had no record of this VIN. Nothing was being collected; the local flags lied.  | `connection_status = 'N'` (Not connected) |

Both are successes — only the wording differs. In both cases `telematics_provider` is set to
`none` when it was `linkbycar` (a vehicle that also carries a Flespi box keeps its provider),
and any other row sharing the same VIN is corrected the same way, since one VIN maps to
exactly one LinkByCar registration.

### 422 Unprocessable Entity

Nothing could be sent to the provider. Two cases, distinguished by the message:

```json
{ "status": false, "message": "Ce véhicule est introuvable." }
{ "status": false, "message": "Ce véhicule n'a pas de VIN ; il ne peut pas être déconnecté de LinkByCar." }
```

### 503 Service Unavailable

LinkByCar is unreachable or answered an error. Nothing was written locally — the vehicle is
still connected. Transient: retry after a short delay.

```json
{ "status": false, "message": "Le service de connexion LinkByCar est temporairement indisponible." }
```

---

## 2. Company LinkByCar summary

```
GET /api/admin/vehicles/linkbycar/companies/{company_id}
```

`company_id` is a **`companies.id`** — the same id the vehicles list sends as its `company_id`
filter. It is **not** the `users.id` behind the `{company}` binding used by the ANTAI and
Companies modules.

### 200 OK

```json
{
  "status": true,
  "message": "État LinkByCar de la société.",
  "data": {
    "company": { "id": 77, "name": "Groupe ILEX Ascenseurs- Holding MEDIALE" },
    "vehicles_total": 255,
    "connected": 39,
    "pending": 1,
    "eligible": 1,
    "not_connected": 214,
    "targets": { "linked": 40, "with_vin": 250 }
  }
}
```

| Field                | Meaning |
|----------------------|---------|
| `vehicles_total`     | Every vehicle of the company, archived included. |
| `connected` / `pending` / `eligible` / `not_connected` | Breakdown by `connection_status` (`C` / `P` / `E` / `N`). |
| `targets.linked`     | How many vehicles the default disconnect would send to LinkByCar (status `C` or `P`, VIN present). |
| `targets.with_vin`   | How many the catch-up disconnect would send (`include_all: true`). |

### 404 Not Found

```json
{ "status": false, "message": "Société introuvable." }
```

Also returned for a `companies` row with no `user_id`: it owns no vehicles under the tenant
key, so it is indistinguishable from a missing company here.

---

## 3. Disconnect a whole company

```
POST /api/admin/vehicles/linkbycar/companies/{company_id}/disconnect
```

```json
{ "include_all": false }
```

| Field         | Type    | Default | Description |
|---------------|---------|---------|-------------|
| `include_all` | boolean | `false` | `false` — send only vehicles whose status is `C` or `P`. `true` — send every vehicle that has a VIN, whatever the local status claims. |

**When to offer `include_all`.** Local flags drift: a vehicle removed on LinkByCar's side, an
import that reset the column. Only the provider knows the truth, so a catch-up run has to ask
about vehicles that already look disconnected. It costs one HTTP call per VIN, so it is
slower — offer it as an explicit opt-in, not the default.

### 200 OK — work queued

```json
{
  "status": true,
  "message": "Déconnexion LinkByCar lancée pour 40 véhicule(s).",
  "data": { "queued": 40, "batches": 1, "include_all": false }
}
```

One HTTP round-trip per VIN means a company cannot be processed inside a request, so the work
is split into batches of 100 and queued. `queued` is the number of vehicles, `batches` the
number of jobs.

### 200 OK — nothing to do

```json
{
  "status": true,
  "message": "Aucun véhicule à déconnecter de LinkByCar pour cette société.",
  "data": { "queued": 0, "batches": 0, "include_all": false }
}
```

### 404 / 422

`404` as in the summary endpoint. `422` when `include_all` is not a boolean.

---

## 4. Verify against LinkByCar

```
GET /api/admin/vehicles/linkbycar/companies/{company_id}/verify
```

**This is the only read here that does not just believe our own columns.** Endpoints 2 and 3
report `connection_status`, which is what the disconnect itself writes — they agree with
themselves by construction and prove nothing. This one pages LinkByCar's own vehicle listing
(`GET /v1/vehicles`, following `totalCount`) and cross-references it with the company's VINs.

Slower than the summary — about 2s for a ~300-vehicle account — so it is on demand, not polled.

### 200 OK

```json
{
  "status": true,
  "message": "65 véhicule(s) sont encore enregistrés chez LinkByCar.",
  "data": {
    "company": { "id": 77, "name": "Groupe ILEX Ascenseurs- Holding MEDIALE" },
    "checked": 250,
    "provider_total": 292,
    "still_registered_count": 65,
    "still_registered": [
      {
        "vehicle_id": 8421,
        "numberplate": "GE-416-GE",
        "vin": "VF1...",
        "archived": true,
        "local_status": "N",
        "provider_status": "ENABLED",
        "provider_activity_status": "ONLINE"
      }
    ],
    "linked_but_absent_count": 0,
    "clean": false
  }
}
```

| Field | Meaning |
|---|---|
| `checked` | Company vehicles with a VIN that were cross-referenced (archived included). |
| `provider_total` | Size of LinkByCar's whole listing for the DADYCAR account, all companies. |
| `still_registered` | Vehicles LinkByCar still holds. `local_status` is our column, `provider_status` is theirs (`ENABLED` / `PENDING`). |
| `linked_but_absent_count` | Drift the other way: we call them `C`/`P`, LinkByCar has no record. |
| `clean` | `true` when LinkByCar holds none of this company's vehicles — the "safe" verdict. |

**Rows whose `local_status` is not `C`/`P` are the reason `include_all` exists.** On the ILEX
account, 25 of the 65 still-registered vehicles were locally marked `N` — 9 of them archived
and still `ENABLED`/`ONLINE` upstream. A default (linked-only) disconnect would have left every
one of them collecting and billing.

### 503 Service Unavailable

LinkByCar is unreachable, **or the listing came back incomplete** (fewer vehicles than the
`totalCount` it reported). The incomplete case is refused rather than answered, because a
truncated read would report vehicles as gone when they are merely on a page that never arrived
— the one wrong answer this endpoint must never give.

### 404 Not Found

Unknown company, same envelope as endpoint 2.

---

## Frontend integration notes

Implemented in `src/app/(dashboard)/vehicles/page.tsx` and
`src/presentation/components/vehicles/company-linkbycar-disconnect-dialog.tsx`.

- **Row action.** Offered when the row has a VIN and `connection_status.color` is `success`
  (Connected) or `warning` (Pending) — the colors are locale-independent, the labels are not.
  Rows whose local status wrongly claims "not connected" are handled by the company catch-up
  sweep rather than by cluttering every row menu.
- **Confirmation.** Destructive confirmation dialog before the call; the copy says data
  collection stops and the vehicle can be reconnected later.
- **Toast.** Pick it from `outcome`, not from the response message: `api.post()` unwraps the
  envelope to `data` and drops the message.
- **After success.** Refetch the row — `connection_status` moves to Eligible or Not connected
  and `provider` may clear.
- **Company action.** A toolbar button that reads the vehicles list's own company filter, so
  there is no second company picker. With no company selected the dialog says which filter to
  set rather than sitting disabled with no explanation.
- **Progress.** There is no per-batch tracking table, so the dialog polls the summary every 4s
  (up to ~3 minutes) and reports `connected + pending` as "still linked". With `include_all`
  that number reaching zero does **not** mean the sweep finished — the dialog says so instead
  of claiming completion.
- **Proof.** A "Vérifier chez LinkByCar" button sits below, available before and after the run:
  before, it shows the real target set; after, it is the actual evidence the vehicles are gone.
  It lists the plates LinkByCar still holds with `local_status → provider_status` side by side,
  and flags how many of them the local status already (wrongly) calls disconnected.

## Ops

The queued work runs on a dedicated `linkbycar` queue. Its worker is scheduled in
`app/Console/Kernel.php` alongside the `antai` and `fps-import` workers:

```
queue:work --queue=linkbycar --stop-when-empty --max-time=840
```

Without that scheduler entry running, batches sit in the `jobs` table and the FE progress
readout never moves (the dialog falls back to "processing continues in the background").
