Files
ApiCore/docs/erp-inventar-export.md
T
Nick SzaunigandClaude Opus 5.5 e28cc8e0f3 fix(api-keys): Fehlversuche je Client-Netz zählen (IPv6 /64)
Wie der Throttler seit 886b4e7: die Sperre nach abgelehnten Schlüsseln
zählt über rateLimitSubject, damit wechselnde Adressen im eigenen
IPv6-/64 das Budget nicht zurücksetzen. Im Log steht weiter die Adresse.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
2026-10-12 11:47:05 +02:00

359 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ERP-Inventar-Export (ApiCore → ERPNext)
Stand: 2026-10-12 · Vertragsversion `1` · Betrifft: ApiCore-Backend (`src/erp-export/`), ERPNext-Abgleich (Teilprojekt T8)
Nur lesende Schnittstelle, über die ERPNext nachts den Inventar-Spiegel holt (ERPNext-Spec `2026-10-12-erpnext-ausbau-design.md`, Abschnitt 8.3, Entscheidungen E2 und E3). ApiCore bleibt führend für Bestand, Chargen und MHD; ERPNext spiegelt nur und bucht daraus Vertrauensschrank und Eigenbedarf.
Der ERPNext-Importer wird genau nach diesem Dokument gebaut. Änderungen am Vertrag gehen deshalb nur additiv (siehe [Versionierung](#10-versionierung)).
## 1. Zugang
| Punkt | Festlegung |
|---|---|
| Verfahren | API-Schlüssel als Bearer-Token: `Authorization: Bearer ack_<8 Zeichen>_<43 Zeichen>` |
| Recht | `erp-inventory:read` („Inventar-Spiegel lesen (ERPNext)“). Öffnet ausschließlich die vier Endpunkte unten. |
| Schreiben | Nicht möglich. Alle Endpunkte sind `GET`; das Recht öffnet keinen anderen Endpunkt (auch nicht `/shop-sync`). |
| Speicherung | ApiCore speichert nur den SHA-256-Hash. Gesucht wird über den Hash (eindeutiger Index), nie durch Vergleich des Geheimnisses. Der Schlüssel selbst wird genau einmal beim Anlegen angezeigt. |
| Abschalten | Jederzeit widerrufbar (wirkt sofort), optional mit Ablaufdatum. |
| Absender | Optional nur von bestimmten IP-Adressen/Netzen (Allowlist am Schlüssel). |
| Fehlversuche | Nach 20 abgelehnten Schlüsseln (fehlerhaft, unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten antwortet ApiCore dieser IP-Adresse (bei IPv6 dem ganzen /64-Netz) mit `429`, auch für richtige Schlüssel. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. |
| Protokoll | Abgelehnte Schlüssel werden mit Grund, Präfix (`ack_abcd1234`) und IP protokolliert, nie mit dem Schlüssel. Query-Strings landen nicht im Log. |
| Trennung | Eigener Schlüssel nur für ERPNext. Der Wabenhain-Schlüssel (`shop-sync:*`) kommt an diese Endpunkte nicht heran und umgekehrt. |
Benutzer mit dem Recht `erp-inventory:read` (z. B. die Rolle Administrator) können die Endpunkte mit ihrem Login ebenfalls lesen, etwa zum Prüfen.
### Schlüssel anlegen (Inhaber)
1. ApiCore → Verwaltung → Automatisierung → API-Schlüssel.
2. Name: `ERPNext`. Unter „Was darf der Schlüssel?“ **nur** „Inventar-Spiegel lesen (ERPNext)“ ankreuzen (den vorausgewählten Shop-Haken entfernen).
3. IP-Adressen: die Adresse, von der ERPNext ApiCore erreicht (siehe [Rollout](#11-rollout)). Unbekannt? Leer lassen, einmal abrufen, dann am Schlüssel „Adressen ändern“ und die zuletzt benutzte Adresse übernehmen.
4. Schlüssel kopieren und **nur** als Kubernetes-Secret für den ERPNext-Abgleich hinterlegen (nicht in `config.json`, nicht ins Repo, nicht in Tickets).
## 2. Allgemeines
- Basis: `https://<apicore-host>/api/v1/export/inventar`
- Antworten: JSON (UTF-8), `Cache-Control: no-store`.
- Zeitpunkte: ISO 8601 in UTC mit Millisekunden, z. B. `2026-10-01T08:15:30.123Z`. Kalendertage (Abfülldatum, MHD, Erntedatum) stehen als Mitternacht UTC, z. B. `2026-08-01T00:00:00.000Z`; der Tag ist der Datumsteil.
- Kennungen (`id`, `productId`, `batchId`) sind undurchsichtige Zeichenketten (cuid), stabil über die Lebensdauer des Datensatzes.
- Mengen in **Stück** (Gläser bzw. hergestellte Stück) sind ganze Zahlen; Gewichte stehen mit Einheit im Feldnamen (`fillWeightGrams`, `netWeightKg`).
- Unbekannte Query-Parameter werden mit `400` abgelehnt.
### Seitenweises Lesen
`produkte`, `chargen` und `bewegungen` liefern Seiten in einer gemeinsamen Hülle:
| Feld | Typ | Bedeutung |
|---|---|---|
| `contractVersion` | number | Vertragsversion, derzeit `1` |
| `generatedAt` | string (Zeitpunkt) | Zeitpunkt der Antwort |
| `items` | array | Einträge dieser Seite, stabil sortiert |
| `nextCursor` | string \| null | Position hinter dem letzten Eintrag. Bei leerer Seite der übergebene `nach`-Wert (oder `null`, wenn keiner übergeben wurde). |
| `hasMore` | boolean | `true`, wenn hinter dieser Seite weitere Einträge liegen |
| Parameter | Typ | Bedeutung |
|---|---|---|
| `limit` | Ganzzahl 1–500, Vorgabe 100 | Einträge je Seite |
| `nach` | Cursor | `nextCursor` der vorigen Antwort, unverändert. Undurchsichtig (base64url); nur von dem Endpunkt gültig, der ihn ausgegeben hat. |
Ablauf: ohne `nach` beginnen, dann mit `nach=<nextCursor>` weiterlesen, solange `hasMore` `true` ist.
`produkte` und `chargen` sind Momentaufnahmen und werden in jedem Lauf vollständig gelesen. Ändert sich zwischen zwei Seiten ein Bestand, gleicht der nächste Lauf das aus. Bei `bewegungen` speichert der Importer den letzten `nextCursor` und liest im nächsten Lauf dort weiter.
## 3. `GET /produkte` – Artikel
Alle ApiCore-Artikel (auch eingestellte), sortiert nach `id` aufsteigend.
| Feld | Typ | Bedeutung |
|---|---|---|
| `id` | string | ApiCore-Produkt-ID |
| `name` | string | Bezeichnung |
| `sku` | string \| null | Artikelnummer in ApiCore (eindeutig, optional) |
| `kind` | `BOTTLED_HONEY` \| `MANUFACTURED` | Wo der Bestand liegt: Honig je Abfüllung, andere Waren (Kerzen, Propolis …) je Herstellungscharge |
| `shopCategory` | `HONIG` \| `KERZEN` \| `PROPOLIS` \| `SONSTIGES` | Kategorie/Sorte des Artikels |
| `status` | `ACTIVE` \| `DISCONTINUED` | eingestellt = nicht mehr hergestellt; Restbestände können noch existieren |
| `packagingSize` | number \| null | Gebindegröße, Einheit in ApiCore nicht festgelegt; Konvention **Gramm** (wie der Wabenhain-Import annimmt) |
| `weight` | number \| null | Gewicht, gleiche Einheiten-Konvention |
| `salesUnit` | string \| null | Verkaufseinheit, z. B. `Glas` |
| `updatedAt` | string (Zeitpunkt) | letzte Änderung am Artikel |
Für Honig ist die verlässliche Füllmenge `fillWeightGrams` an der Charge (siehe unten), nicht `packagingSize`.
**Verknüpfung zu den Shop-Artikeln `WH-<slug>`** (Wabenhain kennt die ApiCore-IDs):
- Honig: Wabenhain führt ein Shop-Produkt je Abfüllung; `Product.apicoreBottlingId` = `chargen[].id` mit `batchType = BOTTLING`. Der ApiCore-Artikel dazu steht in `chargen[].productId`.
- Andere Waren: `Product.apicoreProductId` = `produkte[].id` (Artikel mit `kind = MANUFACTURED`).
## 4. `GET /chargen` – Chargen mit Rest
Erst alle Abfüllungen (`BOTTLING`), dann alle Herstellungschargen (`PRODUCTION`), jeweils nach `id` aufsteigend. Rohstoff-Chargen (Wachs, Propolis) sind keine Ware und fehlen bewusst.
| Feld | Typ | Bedeutung |
|---|---|---|
| `id` | string | ApiCore-ID der Charge |
| `batchType` | `BOTTLING` \| `PRODUCTION` | Abfüllung (Honig) oder Herstellungscharge |
| `lotNumber` | string | Losnummer (MHD-Etikett). Eindeutig **je Art**; über beide Arten hinweg nicht garantiert. Eindeutiger Schlüssel ist (`batchType`, `id`). |
| `productId` | string \| null | ApiCore-Artikel. Bei Abfüllungen optional (`null` = keinem Artikel zugeordnet). |
| `date` | string (Kalendertag) | Abfüll- bzw. Herstelldatum |
| `bestBeforeDate` | string (Kalendertag) \| null | MHD; bei Abfüllungen immer gesetzt, bei Herstellungschargen optional |
| `initialQuantity` | Ganzzahl (Stück) | abgefüllte Gläser bzw. hergestellte Stück |
| `remainingQuantity` | Ganzzahl (Stück) | Restbestand = `initialQuantity` + Summe aller Bewegungen. Kann negativ sein (Shop hat mehr verkauft als gebucht; in ApiCore rot „Prüfen“). |
| `fillWeightGrams` | number \| null | Füllmenge je Glas in Gramm; nur `BOTTLING` |
| `honeyType` | string \| null | Sorte der Honigcharge hinter der Abfüllung (z. B. `Sommertracht`); nur `BOTTLING`, `null` wenn nicht erfasst |
Hinweise für den Abgleich:
- `remainingQuantity` ist der Sollwert für den Bestandsabgleich (Stock Reconciliation) im Lager „Imkerei (ApiCore)“. Er enthält alle Bewegungen bis zum Abruf, auch die der letzten Minuten, die `/bewegungen` noch zurückhält.
- Wird `initialQuantity` in ApiCore korrigiert, ändert sich der Rest sofort mit.
- Eine irrtümlich angelegte Herstellungscharge ohne Bewegungen kann in ApiCore gelöscht werden; sie fehlt dann in der Liste und ist im Spiegel auf 0 zu setzen. Abfüllungen und Chargen mit Bewegungen werden nie gelöscht.
## 5. `GET /bewegungen` – Bestandsbewegungen
Jede Bestandsänderung einer Abfüllung oder Herstellungscharge, nie gelöscht (Fehler werden in ApiCore gegengebucht). Sortiert nach (`createdAt`, `id`) aufsteigend.
| Parameter | Typ | Bedeutung |
|---|---|---|
| `seit` | ISO-Zeitpunkt oder -Datum | Nur Bewegungen ab diesem Zeitpunkt (einschließlich). Für den ersten Lauf, z. B. `seit=2026-01-01T00:00:00Z`. Nicht zusammen mit `nach`. |
| `nach` | Cursor | Weiterlesen hinter der zuletzt gelieferten Bewegung |
| `limit` | 1–500 | wie oben |
Ohne `seit` und `nach` beginnt die Liste bei der ältesten Bewegung.
**Wartezeit:** Es erscheinen nur Bewegungen, die älter als **5 Minuten** sind. Eine Buchung bekommt ihren Zeitstempel beim Einfügen, sichtbar wird sie erst beim Abschluss ihrer Transaktion; ohne Wartezeit könnte der Cursor über eine noch offene, frühere Buchung hinweglaufen. Die Antwort nennt die Grenze zusätzlich:
| Feld | Typ | Bedeutung |
|---|---|---|
| `settledUntil` | string (Zeitpunkt) | Bis hierhin ist die Liste vollständig (Abrufzeit minus 5 Minuten) |
Felder je Bewegung:
| Feld | Typ | Bedeutung |
|---|---|---|
| `id` | string | ApiCore-ID der Bewegung; zum Entdoppeln im Importer |
| `createdAt` | string (Zeitpunkt) | Buchungszeitpunkt |
| `batchType` | `BOTTLING` \| `PRODUCTION` | Art der Charge |
| `batchId` | string | `chargen[].id` |
| `lotNumber` | string | Losnummer der Charge (zur Bequemlichkeit) |
| `productId` | string \| null | Artikel der Charge |
| `type` | siehe Tabelle | Art der Bewegung |
| `reason` | siehe Tabelle \| null | Grund, nur bei `WITHDRAWAL` |
| `quantity` | Ganzzahl (Stück) | mit Vorzeichen: **minus = aus dem Bestand**, plus = zurück in den Bestand |
| `type` | `reason` | Vorzeichen | Bedeutung | Behandlung in ERPNext (Spec 8.3) |
|---|---|---|---|---|
| `WITHDRAWAL` | `HOFLADEN` | − | Ware in den **Vertrauensschrank** gestellt | Umlagerung „Imkerei (ApiCore)“ → „Vertrauensschrank“ |
| `WITHDRAWAL` | `EIGENBEDARF` | − | Eigenverbrauch | unentgeltliche Wertabgabe zu Herstellkosten |
| `WITHDRAWAL` | `BRUCH` | − | Bruch | nur im Spiegel |
| `WITHDRAWAL` | `PROBE` | − | Probe/Muster | nur im Spiegel |
| `WITHDRAWAL` | `SONSTIGES` | − | sonstige Entnahme | nur im Spiegel |
| `SHOP_SALE` | `null` | − | **Verkauf** im Wabenhain-Shop (bezahlt) | nur im Spiegel; die Rechnung kommt aus dem Shop-Import |
| `SHOP_RETURN` | `null` | + | **Retoure**/Storno einer bezahlten Shop-Bestellung | nur im Spiegel |
| `CORRECTION` | `null` | ± | Inventurkorrektur (gezählt minus gebucht), auch zum Gegenbuchen einer Fehlbuchung | nur im Spiegel |
Rohstoff-Bewegungen (Wachs, Propolis, Art `CONSUMPTION`) gehören nicht zur Ware und erscheinen nie. Kommt künftig ein weiterer `type` oder `reason` hinzu, behandelt der Importer ihn als „nur im Spiegel“ und meldet ihn im Cockpit.
Ablauf im Importer: erster Lauf mit `seit=<Startdatum>`, danach immer mit dem gespeicherten `nextCursor`. Derselbe Cursor liefert bei erneutem Abruf dieselben Bewegungen (plus inzwischen hinzugekommene); der Importer entdoppelt trotzdem über `id`.
## 6. `GET /ernten` – Erntemengen je Jahr
Grundlage für die Honigkosten je kg (Imkerei-Ausgaben des Jahres / geerntete kg). Nicht seitenweise (eine Zeile je Jahr).
| Parameter | Typ | Bedeutung |
|---|---|---|
| `jahr` | Ganzzahl 1900–2100, optional | nur dieses Kalenderjahr; ohne Angabe alle Jahre mit Ernten |
| Feld | Typ | Bedeutung |
|---|---|---|
| `contractVersion` | number | `1` |
| `generatedAt` | string (Zeitpunkt) | Zeitpunkt der Antwort |
| `years[].year` | Ganzzahl | Kalenderjahr (aus dem Erntedatum, UTC) |
| `years[].netWeightKg` | number | Summe der Nettogewichte in **kg**, auf Gramm (3 Nachkommastellen) gerundet |
| `years[].harvestCount` | Ganzzahl | Anzahl Ernten im Jahr |
| `years[].harvestsWithoutNetWeight` | Ganzzahl | davon ohne erfasstes Nettogewicht (zählen nicht zur Menge) |
Definition wie die ApiCore-Statistik „Honigertrag je Jahr“: Summe `netWeight` der Ernten mit Erntedatum im Jahr. Mit `jahr` kommt immer genau eine Zeile, bei einem Jahr ohne Ernten mit Nullen. Ist `harvestsWithoutNetWeight > 0`, sind die Kosten je kg zu hoch – der Importer sollte das im Cockpit anzeigen. Standorte, Völker und Notizen sind nicht enthalten.
## 7. Fehler
Fehlerantworten sind JSON mit `statusCode` und `message` (bei Validierungsfehlern `message` als Liste).
| Status | Wann | Verhalten des Importers |
|---|---|---|
| `400` | `limit` außerhalb 1–500 oder keine Ganzzahl; `nach` kein gültiger Cursor oder von einem anderen Endpunkt; `seit` kein ISO-Zeitpunkt; `seit` und `nach` zusammen; `jahr` außerhalb 1900–2100; unbekannter Parameter | Programmfehler – Schritt abbrechen, im Cockpit melden |
| `401` | Schlüssel fehlt, ist fehlerhaft, unbekannt, widerrufen oder abgelaufen | Schritt abbrechen; Hinweis „ApiCore-Schlüssel prüfen“ |
| `403` | Schlüssel ohne `erp-inventory:read`, oder IP-Adresse nicht in der Allowlist des Schlüssels | Schritt abbrechen; Hinweis „Recht bzw. Adressen am Schlüssel prüfen“ |
| `429` | zu viele Fehlversuche oder Anfragen von dieser Adresse | Schritt abbrechen, im nächsten Lauf erneut |
| `5xx` / keine Antwort | Störung in ApiCore | Schritt abbrechen, im nächsten Lauf erneut |
Ein fehlgeschlagener Abruf ist nie „leer“: der Importer setzt dann keine Bestände auf 0 und schiebt keinen Cursor weiter. Die anderen Schritte des Abgleich-Jobs laufen weiter.
## 8. Beispiele
Alle Werte synthetisch.
```http
GET /api/v1/export/inventar/produkte?limit=2
Authorization: Bearer <ERPNEXT_APICORE_KEY>
```
```json
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:04.512Z",
"items": [
{
"id": "cmg0honig500sommer01",
"name": "Sommertracht 500 g",
"sku": "HON-SO-500",
"kind": "BOTTLED_HONEY",
"shopCategory": "HONIG",
"status": "ACTIVE",
"packagingSize": 500,
"weight": 500,
"salesUnit": "Glas",
"updatedAt": "2026-09-02T07:41:10.000Z"
},
{
"id": "cmg0kerze0bienen0001",
"name": "Bienenwachskerze klein",
"sku": null,
"kind": "MANUFACTURED",
"shopCategory": "KERZEN",
"status": "ACTIVE",
"packagingSize": null,
"weight": 120,
"salesUnit": "Stück",
"updatedAt": "2026-09-20T15:03:55.000Z"
}
],
"nextCursor": "eyJrIjoicHJvZHVrdGUiLCJpZCI6ImNtZzBrZXJ6ZTBiaWVuZW4wMDAxIn0",
"hasMore": true
}
```
```http
GET /api/v1/export/inventar/chargen?limit=500
```
```json
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:05.130Z",
"items": [
{
"id": "cmg0abf0sommer2026a1",
"batchType": "BOTTLING",
"lotNumber": "L-2026-014",
"productId": "cmg0honig500sommer01",
"date": "2026-08-01T00:00:00.000Z",
"bestBeforeDate": "2028-08-01T00:00:00.000Z",
"initialQuantity": 60,
"remainingQuantity": 49,
"fillWeightGrams": 500,
"honeyType": "Sommertracht"
},
{
"id": "cmg0her0kerze2026b01",
"batchType": "PRODUCTION",
"lotNumber": "K-2026-003",
"productId": "cmg0kerze0bienen0001",
"date": "2026-09-01T00:00:00.000Z",
"bestBeforeDate": null,
"initialQuantity": 20,
"remainingQuantity": 19,
"fillWeightGrams": null,
"honeyType": null
}
],
"nextCursor": "eyJrIjoiY2hhcmdlbiIsInQiOiJQIiwiaWQiOiJjbWcwaGVyMGtlcnplMjAyNmIwMSJ9",
"hasMore": false
}
```
```http
GET /api/v1/export/inventar/bewegungen?seit=2026-01-01T00:00:00Z&limit=3
```
```json
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:05.877Z",
"settledUntil": "2026-10-13T01:55:05.877Z",
"items": [
{
"id": "cmg0bew000000000001",
"createdAt": "2026-09-05T16:20:11.402Z",
"batchType": "BOTTLING",
"batchId": "cmg0abf0sommer2026a1",
"lotNumber": "L-2026-014",
"productId": "cmg0honig500sommer01",
"type": "WITHDRAWAL",
"reason": "HOFLADEN",
"quantity": -6
},
{
"id": "cmg0bew000000000002",
"createdAt": "2026-09-06T09:02:47.118Z",
"batchType": "BOTTLING",
"batchId": "cmg0abf0sommer2026a1",
"lotNumber": "L-2026-014",
"productId": "cmg0honig500sommer01",
"type": "SHOP_SALE",
"reason": null,
"quantity": -3
},
{
"id": "cmg0bew000000000003",
"createdAt": "2026-09-12T18:44:03.950Z",
"batchType": "BOTTLING",
"batchId": "cmg0abf0sommer2026a1",
"lotNumber": "L-2026-014",
"productId": "cmg0honig500sommer01",
"type": "WITHDRAWAL",
"reason": "EIGENBEDARF",
"quantity": -2
}
],
"nextCursor": "eyJrIjoiYmV3ZWd1bmdlbiIsInRzIjoiMjAyNi0wOS0xMlQxODo0NDowMy45NTBaIiwiaWQiOiJjbWcwYmV3MDAwMDAwMDAwMDAzIn0",
"hasMore": true
}
```
```http
GET /api/v1/export/inventar/ernten?jahr=2026
```
```json
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:06.240Z",
"years": [
{
"year": 2026,
"netWeightKg": 184.35,
"harvestCount": 7,
"harvestsWithoutNetWeight": 1
}
]
}
```
```json
{ "statusCode": 400, "message": "Ungültiger Cursor - bitte nextCursor der vorigen Seite unverändert als \"nach\" übergeben", "error": "Bad Request" }
```
## 9. Was nicht enthalten ist
Bewusst nicht im Export (Feld-Allowlist im Code, `src/erp-export/erp-export.service.ts`): Notizen an Chargen und Bewegungen, Benutzer (wer gebucht hat), API-Schlüssel und Shop-Anfrage-Kennungen, Artikelbeschreibungen (in ApiCore interne Notizen), Standorte, Völker, Behandlungen, Rohstoffe. Neue Felder an den ApiCore-Modellen erscheinen erst, wenn sie hier ausdrücklich ergänzt werden.
## 10. Versionierung
`contractVersion` steigt nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in der Bedeutung geändert). Neue Felder, neue Enum-Werte und neue Endpunkte kommen ohne Versionswechsel; der Importer ignoriert unbekannte Felder und bricht bei einer anderen `contractVersion` ab, ohne etwas zu ändern.
## 11. Rollout
1. ApiCore ausrollen. Keine Datenbank-Migration nötig: das Recht `erp-inventory:read` legt ApiCore beim Start an und gibt es der Systemrolle Administrator.
2. Schlüssel anlegen (Abschnitt 1).
3. Schlüssel als Kubernetes-Secret für den ERPNext-Abgleich ablegen, dazu die Basis-URL (`…/api/v1`) als Konfiguration.
4. Erreichbarkeit: wie der Wabenhain-Shop über die öffentliche ApiCore-Adresse. Dann ist die Absender-IP die Ausgangsadresse des Clusters, und die Allowlist des Schlüssels kann genau diese Adresse enthalten. Ein Aufruf über den clusterinternen Service ginge mit wechselnder Pod-Adresse ein und bräuchte eine NetworkPolicy-Freigabe; die Allowlist taugt dann nur noch als Netzbereich.
5. Probeabruf aller vier Endpunkte, dann den nächtlichen Schritt aktivieren.