Files
ApiCore/docs/erp-inventar-export.md

20 KiB
Raw Permalink Blame History

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 ungültigen Schlüsseln (unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten bekommt diese IP-Adresse (bei IPv6 das ganze /64-Netz) für weitere ungültige Schlüssel 15 Minuten lang 429; die Zählung gilt für alle ApiCore-Instanzen gemeinsam. Ein gültiger Schlüssel wird davon nie abgewiesen, auch nicht von einer gesperrten Adresse – sonst könnte jeder, der dieselbe Adresse nutzt oder vortäuscht, ERPNext oder den Shop aussperren. Nicht wie ein Schlüssel aufgebaute Tokens (ack_… im falschen Format) bekommen sofort 401 und zählen nicht. 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. Ein neuer Schlüssel trägt genau ein Recht; einen Schlüssel mit mehreren Rechten lehnt ApiCore beim Anlegen mit 400 ab (früher ausgestellte bleiben gültig). 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 „Berechtigung“ „ERP-Inventar lesen“ wählen (vorausgewählt ist „Shop lesen“). Ein Schlüssel trägt genau ein Recht; einen Schlüssel mit mehreren Rechten lehnt ApiCore ab.
  3. 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.
  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.

Voll-Spiegel für produkte und chargen (verbindlich): ERPNext liest beide Listen in jedem Lauf vollständig neu, beginnend ohne nach, und speichert für sie keinen Cursor über den Lauf hinaus. Die Kennungen (cuid) sind nicht streng monoton: ein während eines Durchlaufs neu angelegter Artikel oder eine neue Charge kann in diesem Durchlauf fehlen und erscheint im nächsten. Ändert sich zwischen zwei Seiten ein Bestand, gleicht ebenfalls der nächste Lauf das aus. Eine bereits bekannte Charge kann so nicht verloren gehen: fehlt sie in einem vollständigen Durchlauf, ist sie gelöscht (siehe Abschnitt 4).

Inkrementell für bewegungen: Der Importer speichert den letzten nextCursor und liest im nächsten Lauf dort weiter. Die Sortierung nach (createdAt, id) zusammen mit der Wartezeit (Abschnitt 5) sorgt dafür, dass keine Bewegung übersprungen wird.

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 ist live: er enthält alle Bewegungen bis zum Abruf. /bewegungen liefert dagegen nur Bewegungen, die älter als 5 Minuten sind. Eine Buchung der letzten Minuten steckt also schon im Rest, ihre Bewegung kommt erst im nächsten Lauf. Der Importer darf deshalb den Rest nicht aus den gelieferten Bewegungen nachrechnen und mit remainingQuantity vergleichen; Sollwert ist immer remainingQuantity, die Bewegungen dienen nur der Einordnung (Vertrauensschrank, Eigenbedarf).
  • Wird initialQuantity in ApiCore korrigiert, ändert sich der Rest sofort mit.
  • Eine irrtümlich angelegte Abfüllung oder 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 JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm; Jahr 2000–2100 Nur Bewegungen ab diesem Zeitpunkt (einschließlich). Für den ersten Lauf, z. B. seit=2026-01-01T00:00:00Z. Nicht zusammen mit nach. Andere ISO-8601-Formen (Wochen, Ordinaltage, ohne Striche, Uhrzeit ohne Zone) → 400; + im Offset URL-kodieren (%2B).
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 nicht in einer der Formen aus Abschnitt 5 oder außerhalb 2000–2100; 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 ungültiger Schlüssel von einer Adresse mit zu vielen Fehlversuchen, oder allgemeines Anfragelimit überschritten 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

  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.