# 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 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:///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=` 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-`** (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=`, 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 ``` ```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.