# Pässe exportieren

> Laden Sie BatteryPass-JSON, PDF und QR-Code einer Passversion sowie die Einheiten Ihrer Organisation als Tabelle herunter.

Alle Exporte sind einfache `GET`-Anfragen mit einem Schlüssel, der den Scope `read` hat. Sie kommen als Dateien
mit dem Header `Content-Disposition: attachment` zurück, der einen fertigen Dateinamen mitbringt.

| Export                              | Operation                                                                                                | Content-Type                     | Dateiname                                 |
| ----------------------------------- | -------------------------------------------------------------------------------------------------------- | -------------------------------- | ----------------------------------------- |
| BatteryPass-v2.0-JSON einer Version | [`exportPassportJson`](https://passport.kabin.sh/docs/de/api-reference/passports/export-passport-json) | `application/json`               | `{gtin}-{serial}-batterypass-v2.json`     |
| PDF einer Version                   | [`exportPassportPdf`](https://passport.kabin.sh/docs/de/api-reference/passports/export-passport-pdf)   | `application/pdf`                | `{gtin}-{serial}-v{versionNo}-{lang}.pdf` |
| QR-Code der Einheit                 | [`getPassportQr`](https://passport.kabin.sh/docs/de/api-reference/passports/get-passport-qr)           | `image/png` oder `image/svg+xml` | `qr-{gtin}-{serial}.png` oder `.svg`      |
| Einheiten als Tabelle               | [`exportUnits`](https://passport.kabin.sh/docs/de/api-reference/catalog/export-units)                  | XLSX oder `text/csv`             | `units-{YYYYMMDD}.xlsx` oder `.csv`       |

Ein `/` in einer Seriennummer wird in Dateinamen zu `_`.

## Passexporte Schritt für Schritt

1. **Version ermitteln**

   JSON und PDF gehören zu einer veröffentlichten Version.
   [`getPassport`](https://passport.kabin.sh/docs/de/api-reference/passports/get-passport) nennt die aktuelle in
   `current_version.version_no`; [`listPassportVersions`](https://passport.kabin.sh/docs/de/api-reference/passports/list-passport-versions)
   listet alle Versionen, die neueste zuerst.

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

   Eine Einheit, die nie veröffentlicht wurde, hat keine Version: Die Versionsexporte antworten mit
   `404 not_found`.

2. **Dateien herunterladen**

   ```bash title="cURL"
   BATTERY_ID=0192a3f0-5b7c-7d21-8e4f-6a2b9c1d3e5f
   # -O -J speichert unter dem Namen aus Content-Disposition
   curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
     "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/versions/3/batterypass-v2"
   curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
     "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/versions/3/pdf?lang=de"
   curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
     "$BATTERYPASS_URL/api/v1/batteries/$BATTERY_ID/passport/qr?format=svg"
   ```

   Im Code lesen Sie zuerst die Version und übernehmen den Dateinamen aus `Content-Disposition`:

   ```ts title="TypeScript"
   // exports.mts: npx tsx exports.mts <battery-id>
   import { writeFile } from 'node:fs/promises';
   import { basename } from 'node:path';

   const BASE = `${process.env.BATTERYPASS_URL}/api/v1`;
   const headers = { Authorization: `Bearer ${process.env.BATTERYPASS_API_KEY}` };
   const batteryId = process.argv[2];

   async function download(path: string) {
     const res = await fetch(BASE + path, { headers });
     if (!res.ok) throw new Error(`${path}: ${res.status} ${(await res.json()).code}`);
     // Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-de.pdf
     const match = /filename="?([^";]+)"?/.exec(res.headers.get('Content-Disposition') ?? '');
     const file = basename(match?.[1] ?? 'download');
     await writeFile(file, Buffer.from(await res.arrayBuffer()));
     console.log(file, res.headers.get('Content-Type'));
   }

   const passportRes = await fetch(`${BASE}/batteries/${batteryId}/passport`, { headers });
   const passport = await passportRes.json();
   const versionNo = passport.current_version?.version_no;
   if (!versionNo) throw new Error(`passport is ${passport.status}: no published version yet`);

   await download(`/batteries/${batteryId}/passport/versions/${versionNo}/batterypass-v2`);
   await download(`/batteries/${batteryId}/passport/versions/${versionNo}/pdf?lang=de`);
   await download(`/batteries/${batteryId}/passport/qr?format=svg`);
   ```

   ```python title="Python"
   # exports.py: python exports.py <battery-id>
   import os
   import re
   import sys

   import requests

   BASE = os.environ["BATTERYPASS_URL"] + "/api/v1"
   session = requests.Session()
   session.headers["Authorization"] = "Bearer " + os.environ["BATTERYPASS_API_KEY"]
   battery_id = sys.argv[1]


   def download(path, **params):
       res = session.get(BASE + path, params=params, timeout=60)
       res.raise_for_status()
       # Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-de.pdf
       match = re.search(r'filename="?([^";]+)"?', res.headers.get("Content-Disposition", ""))
       name = os.path.basename(match.group(1) if match else "download")
       with open(name, "wb") as f:
           f.write(res.content)
       print(name, res.headers["Content-Type"])


   passport = session.get(f"{BASE}/batteries/{battery_id}/passport", timeout=30).json()
   version_no = passport.get("current_version", {}).get("version_no")
   if not version_no:
       sys.exit(f"passport is {passport['status']}: no published version yet")

   download(f"/batteries/{battery_id}/passport/versions/{version_no}/batterypass-v2")
   download(f"/batteries/{battery_id}/passport/versions/{version_no}/pdf", lang="de")
   download(f"/batteries/{battery_id}/passport/qr", format="svg")
   ```

   ```go title="Go"
   // exports: go run . <battery-id>
   package main

   import (
   	"encoding/json"
   	"fmt"
   	"io"
   	"log"
   	"mime"
   	"net/http"
   	"os"
   	"path/filepath"
   )

   var (
   	base = os.Getenv("BATTERYPASS_URL") + "/api/v1"
   	key  = os.Getenv("BATTERYPASS_API_KEY")
   )

   func get(path string) (*http.Response, error) {
   	req, err := http.NewRequest(http.MethodGet, base+path, nil)
   	if err != nil {
   		return nil, err
   	}
   	req.Header.Set("Authorization", "Bearer "+key)
   	res, err := http.DefaultClient.Do(req)
   	if err == nil && res.StatusCode != http.StatusOK {
   		res.Body.Close()
   		return nil, fmt.Errorf("%s: %s", path, res.Status)
   	}
   	return res, err
   }

   func download(path string) error {
   	res, err := get(path)
   	if err != nil {
   		return err
   	}
   	defer res.Body.Close()
   	// Content-Disposition: attachment; filename=04012345000023-SV2027-000184-v3-de.pdf
   	_, params, err := mime.ParseMediaType(res.Header.Get("Content-Disposition"))
   	if err != nil {
   		return err
   	}
   	name := filepath.Base(params["filename"])
   	f, err := os.Create(name)
   	if err != nil {
   		return err
   	}
   	defer f.Close()
   	if _, err := io.Copy(f, res.Body); err != nil {
   		return err
   	}
   	fmt.Println(name, res.Header.Get("Content-Type"))
   	return nil
   }

   func main() {
   	batteryID := os.Args[1]
   	res, err := get("/batteries/" + batteryID + "/passport")
   	if err != nil {
   		log.Fatal(err)
   	}
   	var passport struct {
   		Status         string `json:"status"`
   		CurrentVersion *struct {
   			VersionNo int `json:"version_no"`
   		} `json:"current_version"`
   	}
   	err = json.NewDecoder(res.Body).Decode(&passport)
   	res.Body.Close()
   	if err != nil {
   		log.Fatal(err)
   	}
   	if passport.CurrentVersion == nil {
   		log.Fatalf("passport is %s: no published version yet", passport.Status)
   	}
   	version := fmt.Sprintf("/batteries/%s/passport/versions/%d", batteryID, passport.CurrentVersion.VersionNo)
   	for _, path := range []string{
   		version + "/batterypass-v2",
   		version + "/pdf?lang=de",
   		"/batteries/" + batteryID + "/passport/qr?format=svg",
   	} {
   		if err := download(path); err != nil {
   			log.Fatal(err)
   		}
   	}
   }
   ```

## BatteryPass-JSON

`exportPassportJson` liefert den Export, der bei der Veröffentlichung der Version erzeugt und gespeichert wurde,
im Format des BatteryPass-Ready Data Model v2.0. Er enthält alle Zugriffsstufen einschließlich eingeschränkter
Werte; behandeln Sie die Datei vertraulich. Ob er das JSON-Schema des Datenmodells erfüllt, ist ein Hinweis und
blockiert keine Veröffentlichung: Die Versionsübersicht trägt `batterypass_v2_valid` und
`batterypass_v2_issue_count`, [`getPassportVersion`](https://passport.kabin.sh/docs/de/api-reference/passports/get-passport-version) den
vollständigen Bericht in `batterypass_v2`.

## PDF

`exportPassportPdf` erzeugt eine Version mit allen Stufen (eingeschränkte Zeilen mit ihrer Stufe gekennzeichnet),
der Signatur und dem QR-Code. `lang` ist `de` (Standard) oder `en`. Wie das JSON enthält auch das PDF
eingeschränkte Daten.

## QR-Code

`getPassportQr` erzeugt den QR-Code zum `gs1_digital_link` der Einheit (Fehlerkorrektur M, Ruhezone von vier
Modulen).

- **format** `string` (query, Standard: png): `png` oder `svg`. SVG skaliert verlustfrei und eignet sich für den Druck.
- **size** `integer` (query, Standard: 512): Breite und Höhe des PNG in Pixeln, 128 bis 2048. Bei SVG ohne Wirkung.

Den QR-Code gibt es, sobald die Kennungen gesperrt sind, vor oder mit der ersten Veröffentlichung. Davor lautet
die Antwort `409 conflict`. Der Link darin ändert sich danach nie mehr; ein gedrucktes Etikett bleibt also auch
für spätere Versionen gültig. Für viele Einheiten auf einmal siehe das Rezept
[QR-Etiketten für eine Charge](https://passport.kabin.sh/docs/de/recipes/qr-labels-for-a-batch).

## Einheiten als Tabelle

`exportUnits` liefert die Einheiten, die zu den Filtern passen, mit den Spalten des Tabellenimports: Modell,
Version, die Felder der Einheit und jedes Feld auf Einheitsebene, das einen Wert hat. Ein Mitglied kann die Datei
bearbeiten und im Portal wieder importieren.

- **format** `string` (query, Standard: xlsx): `xlsx` oder `csv`. CSV ist UTF-8 mit Byte Order Mark, durch Kommas getrennt, mit CRLF-Zeilenenden.
- **battery_model_id** `uuid` (query): Nur Einheiten dieses Modells.
- **model_version_id** `uuid` (query): Nur Einheiten dieser Modellversion.
- **passport_status** `string` (query): Nur Einheiten, deren Pass diesen Status hat, z. B. `published`.
- **serial_prefix** `string` (query): Nur Seriennummern, die mit diesem Text beginnen.
- **import_id** `uuid` (query): Nur Einheiten aus diesem Import.

```bash title="cURL"
curl -sS -OJ -H "Authorization: Bearer $BATTERYPASS_API_KEY" \
  "$BATTERYPASS_URL/api/v1/batteries/export?format=csv&passport_status=published&serial_prefix=SV2027-"
```

Eine Datei fasst höchstens 10 000 Einheiten. Bei mehr lautet die Antwort `400 validation_error` auf `filter`
(`out_of_range`): Schränken Sie die Filter ein, etwa nach Modellversion oder Seriennummernpräfix. In CSV-Dateien
bekommt eine Zelle, die mit `=`, `+` oder `@` beginnt oder mit `-` beginnt und keine Zahl ist, ein
vorangestelltes `'`, damit Tabellenprogramme sie nicht als Formel ausführen; der Import entfernt es wieder.

## Fehler

| Status und `code`      | Typische Ursache                                                                                                 |
| ---------------------- | ---------------------------------------------------------------------------------------------------------------- |
| `400 validation_error` | `size` außerhalb von 128 bis 2048; unbekanntes `format` oder `lang`; mehr als 10 000 Einheiten bei `exportUnits` |
| `401 unauthenticated`  | Schlüssel fehlt, ist unbekannt oder widerrufen                                                                   |
| `403 forbidden`        | Dem Schlüssel fehlt der Scope `read`                                                                             |
| `404 not_found`        | Unbekannte Einheit oder keine Version mit dieser Nummer                                                          |
| `409 conflict`         | `getPassportQr`, bevor die Kennungen gesperrt sind                                                               |
| `429 rate_limited`     | Zu viele Anfragen; `Retry-After` abwarten                                                                        |
