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]>
19 KiB
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).
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)
- ApiCore → Verwaltung → Automatisierung → API-Schlüssel.
- Name:
ERPNext. Unter „Was darf der Schlüssel?“ nur „Inventar-Spiegel lesen (ERPNext)“ ankreuzen (den vorausgewählten Shop-Haken entfernen). - IP-Adressen: die Adresse, von der ERPNext ApiCore erreicht (siehe Rollout). Unbekannt? Leer lassen, einmal abrufen, dann am Schlüssel „Adressen ändern“ und die zuletzt benutzte Adresse übernehmen.
- 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
400abgelehnt.
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[].idmitbatchType = BOTTLING. Der ApiCore-Artikel dazu steht inchargen[].productId. - Andere Waren:
Product.apicoreProductId=produkte[].id(Artikel mitkind = 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:
remainingQuantityist 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/bewegungennoch zurückhält.- Wird
initialQuantityin 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.
GET /api/v1/export/inventar/produkte?limit=2
Authorization: Bearer <ERPNEXT_APICORE_KEY>
{
"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
}
GET /api/v1/export/inventar/chargen?limit=500
{
"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
}
GET /api/v1/export/inventar/bewegungen?seit=2026-01-01T00:00:00Z&limit=3
{
"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
}
GET /api/v1/export/inventar/ernten?jahr=2026
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:06.240Z",
"years": [
{
"year": 2026,
"netWeightKg": 184.35,
"harvestCount": 7,
"harvestsWithoutNetWeight": 1
}
]
}
{ "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
- ApiCore ausrollen. Keine Datenbank-Migration nötig: das Recht
erp-inventory:readlegt ApiCore beim Start an und gibt es der Systemrolle Administrator. - Schlüssel anlegen (Abschnitt 1).
- Schlüssel als Kubernetes-Secret für den ERPNext-Abgleich ablegen, dazu die Basis-URL (
…/api/v1) als Konfiguration. - 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.
- Probeabruf aller vier Endpunkte, dann den nächtlichen Schritt aktivieren.