# Pass-Links zurück ins ERP

> Mit einem Schlüssel mit Scope read dem Änderungsfeed folgen und GS1 Digital Link, Passstatus und QR-Code jeder Einheit an ihrem Seriennummernsatz im ERP speichern.

Etiketten, Lieferscheine und Servicewerkzeuge in Ihrem ERP brauchen den Link zum Pass jeder Batterie. In diesem
Ablauf folgt die Middleware mit einem Schlüssel mit Scope `read` dem
[Änderungsfeed](https://passport.kabin.sh/docs/de/api-reference/changes/list-changes) von BatteryPass, liest Einheit und Pass und schreibt
GS1 Digital Link und Passstatus an den Seriennummernsatz im ERP. In BatteryPass wird nichts geschrieben.

```mermaid
sequenceDiagram
  participant MW as Middleware
  participant BP as BatteryPass-API
  participant ERP
  loop alle paar Minuten
    MW->>BP: listChanges mit gespeichertem Cursor und Passtypen (Scope read)
    BP-->>MW: Änderungen, älteste zuerst, next_cursor
    MW->>BP: getBattery (Scope read)
    MW->>BP: getPassport (Scope read)
    MW->>BP: getPassportQr, wenn ein Etikett nötig ist (Scope read)
    MW->>ERP: Digital Link und Status am Seriennummernsatz speichern
    MW->>MW: next_cursor speichern, nachdem das ERP angenommen hat
  end
```

**Auslöser:** Ein Pass wird veröffentlicht, neu veröffentlicht oder zurückgezogen.

**Scopes des Schlüssels:** read

| # | Wer        | Was                                                                                                                                           | Operation                                                                                                       |
| - | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| 1 | Middleware | Fragt den Änderungsfeed mit dem gespeicherten Cursor ab, etwa alle fünf Minuten, gefiltert auf Passänderungen.                                | [`listChanges`](https://passport.kabin.sh/docs/de/api-reference/changes/list-changes.md) (Scope: read)        |
| 2 | Middleware | Liest die Einheit, um Seriennummer und Modell zu erhalten, mit denen sie den Satz im ERP findet.                                              | [`getBattery`](https://passport.kabin.sh/docs/de/api-reference/catalog/get-battery.md) (Scope: read)          |
| 3 | Middleware | Liest den Pass der Einheit mit Status, aktueller Version und GS1 Digital Link.                                                                | [`getPassport`](https://passport.kabin.sh/docs/de/api-reference/passports/get-passport.md) (Scope: read)      |
| 4 | Middleware | Lädt den QR-Code (PNG oder SVG), wenn ein Etikett gedruckt werden soll.                                                                       | [`getPassportQr`](https://passport.kabin.sh/docs/de/api-reference/passports/get-passport-qr.md) (Scope: read) |
| 5 | ERP        | Speichert Digital Link und Passstatus am Seriennummern- oder Equipmentsatz; der Etikettendruck liest sie dort.                                |                                                                                                                 |
| 6 | Middleware | Speichert den Cursor erst, nachdem das ERP die Änderung angenommen hat; nach einem Absturz wiederholt sie Änderungen, statt sie zu verlieren. |                                                                                                                 |

**Sicherheit**

- Ein Schlüssel nur mit dem Scope read; er kann nichts verändern.
- Der Feed liefert dünne Ereignisse ohne Werte; die Daten holt die Middleware danach mit demselben Schlüssel, sodass ein abgefangener Feed nichts preisgibt.
- Änderungen kommen mindestens einmal an; das Schreiben ins ERP ist idempotent (gleicher Link, gleicher Status).

**Datenminimierung**

- Ins ERP gehen nur Digital Link, Passstatus und Versionsnummer, keine Passinhalte.

## Der Änderungsfeed

`GET /api/v1/changes` liefert die Änderungen Ihrer Organisation, älteste zuerst. Jede Änderung nennt nur, was
geschah und welche Entität betroffen ist; den aktuellen Stand lesen Sie an der Entität.

- **types** `string[]` (query): Kommagetrennte Änderungsarten; ohne Angabe alle. Für diesen Ablauf: `passport.published`,
  `passport.republished`, `passport.withdrawn`.

* **cursor** `string` (query): Der `next_cursor` Ihrer letzten Seite; ohne ihn beginnt der Feed am Anfang der Geschichte Ihrer Organisation.
* **limit** `integer` (query, Standard: 50): Höchstens 200.

```json title="200"
{
  "items": [
    {
      "id": "0192f6d2-3c4e-7a5b-8c9d-0e1f2a3b4c5d",
      "occurred_at": "2026-10-24T09:12:40Z",
      "entity_type": "battery",
      "entity_id": "0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f",
      "action": "passport.published"
    }
  ],
  "next_cursor": "eyJ4IjoxODQ0MiwiaSI6IjAxOTJmNmQyLTNjNGUtN2E1Yi04YzlkLTBlMWYyYTNiNGM1ZCJ9"
}
```

| `action`                                                           | `entity_type`           | Danach lesen                                                                                                                                                                                            |
| ------------------------------------------------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `battery.created`                                                  | `battery`               | [`getBattery`](https://passport.kabin.sh/docs/de/api-reference/catalog/get-battery)                                                                                                                   |
| `passport.published`, `passport.republished`, `passport.withdrawn` | `battery` (die Einheit) | [`getPassport`](https://passport.kabin.sh/docs/de/api-reference/passports/get-passport)                                                                                                               |
| `condition.updated`, `lifecycle_event.recorded`                    | `battery`               | [`getCondition`](https://passport.kabin.sh/docs/de/api-reference/lifecycle/get-condition), [`listLifecycleEvents`](https://passport.kabin.sh/docs/de/api-reference/lifecycle/list-lifecycle-events) |
| `model_version.approved`                                           | `model_version`         | [`getModelVersion`](https://passport.kabin.sh/docs/de/api-reference/catalog/get-model-version)                                                                                                        |
| `import.committed`, `import.approved`                              | `import`                | ein Schlüssel mit Scope `import`: [`getImport`](https://passport.kabin.sh/docs/de/api-reference/imports/get-import)                                                                                   |

Drei Regeln machen das Abfragen verlässlich:

- **`next_cursor` ist gesetzt, sobald die Seite Einträge hat, auch auf der letzten Seite.** Speichern Sie ihn und
  senden Sie ihn beim nächsten Mal. Eine leere Seite hat `next_cursor: null`: Behalten Sie den gesendeten Cursor.
- **Eine Änderung erscheint erst, wenn jede ältere Transaktion beendet ist.** Ein Cursor springt deshalb nie über
  eine Änderung, die spät festgeschrieben wurde. Eine Änderung kann einige Sekunden nach dem Ereignis erscheinen.
- **Speichern Sie den Cursor erst, nachdem das ERP die Änderungen der Seite angenommen hat.** Nach einem Absturz
  sehen Sie dieselben Änderungen erneut (mindestens einmal); denselben Link und Status zweimal zu schreiben, schadet
  nicht.

## Abfragen und zurückschreiben

```bash title="cURL"
curl -sS "$BATTERYPASS_URL/api/v1/changes?types=passport.published,passport.republished,passport.withdrawn&limit=100&cursor=$CURSOR" \
  -H "Authorization: Bearer $BATTERYPASS_READ_KEY"

curl -sS "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/passport" \
  -H "Authorization: Bearer $BATTERYPASS_READ_KEY" | jq '{status, passport_id, gs1_digital_link, version: .current_version.version_no}'

curl -sS -o etikett.svg "$BATTERYPASS_URL/api/v1/batteries/0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f/passport/qr?format=svg" \
  -H "Authorization: Bearer $BATTERYPASS_READ_KEY"
```

```ts title="TypeScript"
// passport-links.mts: alle paar Minuten ausführen
import { existsSync, readFileSync, writeFileSync } from 'node:fs';

const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_READ_KEY}` };
const STATE = 'passport-links.cursor';

type Change = { id: string; entity_id: string; action: string };

// Platzhalter: Link und Status an den Seriennummernsatz im ERP schreiben (idempotent).
async function updateErp(serial: string, gtin: string | undefined, link: string | undefined, status: string) {
  console.log(serial, gtin, link, status);
}

const get = async (path: string) => {
  const res = await fetch(`${BASE}${path}`, { headers });
  if (!res.ok) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`);
  return res.json();
};

let cursor = existsSync(STATE) ? readFileSync(STATE, 'utf8') : '';
for (;;) {
  const query = new URLSearchParams({ types: 'passport.published,passport.republished,passport.withdrawn', limit: '100' });
  if (cursor) query.set('cursor', cursor);
  const page: { items: Change[]; next_cursor: string | null } = await get(`/changes?${query}`);
  for (const change of page.items) {
    const passport = await get(`/batteries/${change.entity_id}/passport`);
    await updateErp(passport.serial_number, passport.gtin, passport.gs1_digital_link, passport.status);
  }
  if (!page.next_cursor) break; // leere Seite: gespeicherten Cursor behalten
  cursor = page.next_cursor;
  writeFileSync(STATE, cursor); // erst, nachdem das ERP die Seite angenommen hat
}
```

```python title="Python"
# passport_links.py: alle paar Minuten ausführen
import os

import requests

BASE = os.environ["BATTERYPASS_URL"] + "/api/v1"
STATE = "passport-links.cursor"
session = requests.Session()
session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_READ_KEY"]


def update_erp(serial, gtin, link, status):
    """Platzhalter: Link und Status an den Seriennummernsatz im ERP schreiben (idempotent)."""
    print(serial, gtin, link, status)


def get(path, **params):
    res = session.get(BASE + path, params=params, timeout=30)
    res.raise_for_status()
    return res.json()


cursor = open(STATE).read() if os.path.exists(STATE) else None
while True:
    params = {"types": "passport.published,passport.republished,passport.withdrawn", "limit": 100}
    if cursor:
        params["cursor"] = cursor
    page = get("/changes", **params)
    for change in page["items"]:
        passport = get(f"/batteries/{change['entity_id']}/passport")
        update_erp(passport["serial_number"], passport.get("gtin"), passport.get("gs1_digital_link"), passport["status"])
    if not page["next_cursor"]:
        break  # leere Seite: gespeicherten Cursor behalten
    cursor = page["next_cursor"]
    with open(STATE, "w") as f:  # erst, nachdem das ERP die Seite angenommen hat
        f.write(cursor)
```

Der Pass trägt, was das ERP braucht: `serial_number` und `gtin`, um den Satz zu finden, `gs1_digital_link` für das
Etikett (`{resolver}/01/{gtin}/21/{serial}`), `status` (`published`, `update_needed`, `withdrawn`, …), `passport_id`
und `current_version.version_no`. Ein Etikettendrucker holt den QR-Code mit
[`getPassportQr`](https://passport.kabin.sh/docs/de/api-reference/passports/get-passport-qr) (`format=png` mit `size` oder `format=svg`); er
enthält denselben Digital Link. [`getBattery`](https://passport.kabin.sh/docs/de/api-reference/catalog/get-battery) ergänzt Modell,
Modellversion und Daten, wenn Ihr ERP-Satz sie braucht.

Der erste Lauf ohne Cursor geht durch Ihre gesamte Geschichte; filtern Sie mit `types` und lassen Sie ihn einmal
laufen, bevor Sie ihn einplanen. Wie der Digital Link aufgelöst wird, beschreibt
[Digital Link auflösen](https://passport.kabin.sh/docs/de/guides/resolve-digital-link).

## Was ins ERP gehört

Nur Digital Link, Passstatus und Versionsnummer. Passinhalte bleiben in BatteryPass: Die öffentliche Seite und das
öffentliche JSON hinter dem Digital Link sind immer aktuell, und eingeschränkte Daten dürfen nicht in Systeme mit
größerem Leserkreis kopiert werden.

## Fehlerbehandlung

| Antwort                             | Bedeutung                                      | Was tun                                                                                                                             |
| ----------------------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| `400 validation_error` auf `cursor` | Der gespeicherte Cursor ist nicht verwendbar   | Löschen; der nächste Lauf beginnt am Anfang und wiederholt Änderungen, was nicht schadet, weil derselbe Link zweimal nichts ändert. |
| `400 validation_error` auf `types`  | Eine unbekannte Änderungsart                   | Liste korrigieren.                                                                                                                  |
| `401 unauthenticated`               | Schlüssel fehlt, ist unbekannt oder widerrufen | Geheimnis prüfen; Alarm auslösen.                                                                                                   |
| `403 forbidden`                     | Dem Schlüssel fehlt der Scope `read`           | Den Lese-Schlüssel verwenden.                                                                                                       |
| `404 not_found` bei einer Einheit   | Die Einheit gehört nicht zu Ihrer Organisation | Überspringen und die ID der Änderung protokollieren.                                                                                |
| `409 conflict` bei `getPassportQr`  | Die Kennungen sind noch nicht festgelegt       | Nach dem nächsten `passport.published` erneut fragen.                                                                               |
| `429 rate_limited`                  | Zu viele Anfragen                              | `Retry-After` Sekunden warten; Cursor behalten.                                                                                     |
| `5xx`, Zeitüberschreitung           | Ergebnis unbekannt                             | Mit Backoff wiederholen; der Cursor zeigt noch vor die Seite.                                                                       |
