Merge feat/erp-inventar-export: read-only inventory mirror for ERPNext

Brings the ERPNext inventory export (T8) into main after its review:
strict seit format, settle window on the DB clock, BatchMovement
(createdAt, id) index, one scope per new API key and the failed-attempt
lockout on the shared throttler storage. Conflicts resolved in the
permission catalog (System and ERP-Anbindung groups) and ARCHITECTURE.md
(lockout is Annahme 30).

Gates on the merged state: backend lint, typecheck, 1909 unit, 471 e2e,
build, no schema drift; frontend 929 tests, lint, typecheck, build.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-14 11:44:03 +02:00
commit 2d5c740f7f
33 files changed
+3933 -106

No files matched your search

+4 -2
View File
@@ -26,6 +26,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. | | `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. | | `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. | | `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig. Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig.
@@ -112,7 +113,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`), den Webhook `colony.created` und den Kalendereintrag der Königin-Zuordnung (`CalendarSyncService.enqueue`). Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`, `queen.replaced`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`. 11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Sammelerfassung von Behandlung, Fütterung und Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung, Herstellung, Rohstoffe, Überwinterung, Dokument-Upload), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups. Idempotenz schützt nur, wenn nach dem Commit nichts mehr fehlschlägt. Darum ist das Ereignis-Rückgrat (Abschnitt 10) der Weg für Seiteneffekte (Webhooks, Kalender, Automationsregeln, Benachrichtigungen): Der Schreibpfad nimmt das Domänenereignis in derselben Transaktion auf, Handler im Worker setzen die Wirkung um. Aufgaben, Durchsichten, Varroamessungen, Behandlungen, Fütterungen, Ernten, Völker sowie Bestand und Abfüllung haben keine Post-Commit-Seiteneffekte mehr (Abschnitt 10, Absätze „Aufgaben“, „Durchsichten und Varroa“, „Behandlungen, Fütterungen und Ernten“, „Völker“, „Bestand und Abfüllung“, „Sammelerfassung am Stand“). Wiederkehrende Arbeit läuft als Kubernetes-CronJob über `dist/cli.js`, nie als Timer im Prozess: `@nestjs/schedule` und `ScheduleModule` gibt es nicht mehr, der Wächter `src/runtime/no-in-process-schedulers.spec.ts` hält das fest. `runAfterCommit` (`src/common/after-commit.ts`, best-effort: loggt auf Error-Level, wirft nie) bleibt nur für die Automatik `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` (`AutomationService.evaluateSwarmControlCheck` und `evaluateStockItem`), den Webhook `colony.created` und den Kalendereintrag der Königin-Zuordnung (`CalendarSyncService.enqueue`). Nachzügler für eine spätere Stufe, die weiter über `WebhooksService.dispatch` laufen: Königinnen (`queen.created`, `queen.replaced`), Honigchargen (`batch.created`) und Lager (`product.stock_changed`). Der Wächter `src/common/after-commit-usage.spec.ts` hält die Aufrufer fest. Die früheren `AutomationService.evaluate*`-Wrapper für Durchsicht, Varroa, Behandlung, Fütterung, Volksgesundheit und Rohstoff sind entfernt. Was das Schreiben verhindern muss (z. B. `generateNumber`, Validierung), wirft weiter. Doppelte Automatik-Aufgaben verhindert eine Zeilensperre auf der Regel (`SELECT … FOR UPDATE`). Den Entwurf des Rückgrats beschreibt die Spec `docs/superpowers/specs/2026-10-12-plattform-rueckgrat-und-fahrplan-design.md`.
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Geprüft wird unter einer Zeilensperre auf der Regel (`SELECT … FOR UPDATE`), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Ihr Standardwert 10 und der Rückfall bei unbrauchbarer Einstellung kommen allein aus `varroaThresholdOf` (`automation/varroa-threshold.ts`). Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Automatik-Aufgaben laufen wie jede andere Aufgabe über `TasksService.createInTx(tx, …, null)`: Audit-Eintrag ohne Benutzer und `TaskCreated`. Aus dem Ereignis folgen Kalendereintrag (`calendar-sync.task`) und Webhook `task.created` (`webhooks.forward`); die Regel ruft `WebhooksService` nicht selbst auf. Aufgabe, Warnung (`Notification`, Priorität `WARNUNG`) und der Audit-Eintrag der Regel (`AutomationRule CREATE`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie. 12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Geprüft wird unter einer Zeilensperre auf der Regel (`SELECT … FOR UPDATE`), die bis zum Commit gilt. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen. Ihr Standardwert 10 und der Rückfall bei unbrauchbarer Einstellung kommen allein aus `varroaThresholdOf` (`automation/varroa-threshold.ts`). Die Tagesrate wird bei jeder Korrektur einer Eingangsgröße (Methode, Anzahl, Dauer) neu berechnet; ein mitgesendeter, vom gespeicherten abweichender Wert gilt als manuell gesetzt und gewinnt. Automatik-Aufgaben laufen wie jede andere Aufgabe über `TasksService.createInTx(tx, …, null)`: Audit-Eintrag ohne Benutzer und `TaskCreated`. Aus dem Ereignis folgen Kalendereintrag (`calendar-sync.task`) und Webhook `task.created` (`webhooks.forward`); die Regel ruft `WebhooksService` nicht selbst auf. Aufgabe, Warnung (`Notification`, Priorität `WARNUNG`) und der Audit-Eintrag der Regel (`AutomationRule CREATE`) entstehen in einer Transaktion: Scheitert ein Teil, entsteht nichts davon. Ausgewertet wird in den Handlern `automation.*` (`src/automation/automation.handlers.ts`) in der Zustell-Transaktion, bei Fehler mit Wiederholung; sie rufen `evaluate…In(tx, …)`. Nur `SWARM_MOOD_HIGH` und `STOCK_ITEM_LOW` laufen dauerhaft über `runAfterCommit` in eigener Transaktion (Annahme 11); sie rufen dieselben `evaluate…In`, werfen aber nie.
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`): Ein Schlüssel mit beiden Rechten wird beim Anlegen mit 400 abgelehnt; bereits ausgestellte kombinierte Schlüssel bleiben gültig, die Oberfläche markiert sie und empfiehlt zwei getrennte Schlüssel (neue anlegen, in Wabenhain eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. 14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop, ERPNext) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); die Suche läuft über den Hash, Geheimnisse werden nie verglichen; der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt. Lesen und Buchen sind getrennte API-Schlüssel (`shop-sync:read` bzw. `shop-sync:write`; `erp-inventory:read` zählt als Lesen, Buchungsrecht ist nur `shop-sync:write`, Liste `API_KEY_WRITE_SCOPES`): Ein neuer Schlüssel trägt genau ein Recht: einer mit mehreren Rechten wird beim Anlegen mit 400 abgelehnt (mit Lese- und Buchungsrecht eigens begründet); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig, die Oberfläche markiert sie und empfiehlt getrennte Schlüssel (neue anlegen, im angebundenen System – Wabenhain bzw. ERPNext – eintragen, alten widerrufen). Ein geleakter Lese-Schlüssel kann so nicht buchen, ein Buchungs-Schlüssel nicht lesen. Die Oberfläche bietet nur Rechte an, die der Anlegende selbst hat (alles andere lehnt der Server ohnehin ab). Ungültige Schlüssel werden je Client-Netz gezählt und nach 20 Fehlversuchen in 15 min gesperrt: Annahme 30.
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. 16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
@@ -133,6 +134,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
27. **Sammelerfassung am Stand**: `POST /treatments|feedings|varroa/bulk` schreibt alles oder nichts in einer Transaktion (`@Idempotent()`). Die Völker werden sortiert mit `lockRowsForShare` (`FOR SHARE`, `src/prisma/row-lock.ts`) gesperrt, der Ablauf steckt im Helfer `runBulk`; das Ereignis je Volk trägt `bulkId`. Entscheidung K1: Die Chargennummer des Arzneimittels (`batchNumber`) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird über `bulkId`. Entscheidung K2: Varroa nimmt je Volk einen eigenen Eintrag, ein gemeinsamer Milbenwert wäre erfundene Messung. Unbekannte oder beendete Völker meldet die 400-Antwort als `invalidColonyIds`. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel. 27. **Sammelerfassung am Stand**: `POST /treatments|feedings|varroa/bulk` schreibt alles oder nichts in einer Transaktion (`@Idempotent()`). Die Völker werden sortiert mit `lockRowsForShare` (`FOR SHARE`, `src/prisma/row-lock.ts`) gesperrt, der Ablauf steckt im Helfer `runBulk`; das Ereignis je Volk trägt `bulkId`. Entscheidung K1: Die Chargennummer des Arzneimittels (`batchNumber`) wird einmal eingegeben und nie erzeugt (rechtlich relevantes Bestandsbuch); gruppiert wird über `bulkId`. Entscheidung K2: Varroa nimmt je Volk einen eigenen Eintrag, ein gemeinsamer Milbenwert wäre erfundene Messung. Unbekannte oder beendete Völker meldet die 400-Antwort als `invalidColonyIds`. Der Idempotenz-Schlüssel bindet Benutzer, Schlüssel und Pfad, nicht den Inhalt: Der Client nimmt darum je abweichender Absendung einen neuen Schlüssel.
28. **Skalen und Vorbelegung**: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (`RatingScale direction="lower-is-better"`; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus in `colonyStrength` und neu `broodFrames`, das Altfeld `broodAmount` nur im Bearbeiten-Dialog. Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt. 28. **Skalen und Vorbelegung**: Alle Bewertungsskalen der Durchsicht zeigen „5 = gut“ (Gespeichertes bleibt unverändert). Die Schwarmstimmung ist gespeichert umgekehrt (1 = ruhig, 5 = stark) und wird nur in der Anzeige gedreht (`RatingScale direction="lower-is-better"`; die Beschriftungen nennen die gespeicherten Enden); keine Datenmigration. Waben stehen per Plus/Minus in `colonyStrength` und neu `broodFrames`, das Altfeld `broodAmount` nur im Bearbeiten-Dialog. Werte der letzten Durchsicht erscheinen als sichtbarer Hinweis je Feld mit „Letztes Mal … übernehmen“ und „Alles wie letztes Mal“, nie stillschweigend vorbelegt.
29. **Ampeln**: Varroa wird gelb ab der halben Schwelle der aktiven Regel (`VARROA_WARN_FRACTION = 0.5`, in Milben/Tag), rot ab der Schwelle; der Trend zeigt Milben/Tag mit Schwellenlinie, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (`lib/colonies/status-light.ts`) nimmt den schlechtesten Befund: rot bei `QUEENLESS`, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei `WEAK`/`CONSPICUOUS`, schwachem Zustand, offener Warnung, noch keiner Durchsicht oder mehr als `INSPECTION_STALE_DAYS = 14` Tagen ohne Durchsicht (Letzteres nur mit Leserecht auf Durchsichten). Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test. 29. **Ampeln**: Varroa wird gelb ab der halben Schwelle der aktiven Regel (`VARROA_WARN_FRACTION = 0.5`, in Milben/Tag), rot ab der Schwelle; der Trend zeigt Milben/Tag mit Schwellenlinie, Zeiträume 1/2/3/7 Tage oder „anders“. Die Ampel eines Volkes (`lib/colonies/status-light.ts`) nimmt den schlechtesten Befund: rot bei `QUEENLESS`, kritischem Gesundheits-, Futter- oder Brutzustand oder offener kritischer Meldung; gelb bei `WEAK`/`CONSPICUOUS`, schwachem Zustand, offener Warnung, noch keiner Durchsicht oder mehr als `INSPECTION_STALE_DAYS = 14` Tagen ohne Durchsicht (Letzteres nur mit Leserecht auf Durchsichten). Die Farbe steht nie allein (Symbol und Wort); die Kontraste der Farbtöne prüft ein Test.
30. **Fehlversuchs-Sperre für API-Schlüssel, über alle Pods**: Ungültige Schlüssel (unbekannt, widerrufen, abgelaufen) zählen je Client-Netz – IPv4-Adresse bzw. IPv6-/64, wie der Throttler (`rateLimitSubject`). Nach 20 ungültigen Schlüsseln in einem Fenster von 15 min (ab dem ersten Fehlversuch) bekommt das Netz für weitere ungültige Schlüssel 15 min lang `429` (`src/api-keys/failed-attempt-limiter.ts`, Grenzen `API_KEY_FAILURE_LIMIT`). Ein gültiger Schlüssel wird davon nie abgewiesen: Erst kommt die Suche über den Hash, nur ein ungültiger Schlüssel wird gezählt oder gesperrt. Sonst könnte jeder, der eine gemeinsame Adresse (Cluster-Ausgang, Proxy) nutzt oder vortäuscht, ERPNext oder den Shop aussperren; bei 256 Zufallsbits je Schlüssel kostet das nichts. Falsch aufgebaute Tokens werden ohne Datenbankzugriff mit 401 abgewiesen und zählen nicht. Die Zähler liegen wie die des Throttlers in der UNLOGGED-Tabelle `ThrottlerHit` (über `PrismaThrottlerStorage`: eigener Throttler-Name `api-key-failures`, Schlüssel-Präfix `api-key-failures:`, eine Upsert-Anweisung, nur DB-Uhr `now()`) und gelten damit für alle api-Pods gemeinsam; kein zweiter Zähler im Prozessspeicher. Bei einem DB-Fehler schlägt die Anfrage fehl (fail-closed, wie der Throttler). Abgewiesene Schlüssel werden nur mit Präfix und IP protokolliert, das Erreichen der Grenze einmal je Fenster.
## 7. Kubernetes ## 7. Kubernetes
@@ -225,7 +227,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt | | Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) | | Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten | | Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`, `shop-sync:write`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Lesen und Buchen sind getrennte Schlüssel: `ApiKeysService.create` lehnt einen Schlüssel mit beiden Shop-Rechten mit 400 ab; bereits ausgestellte kombinierte Schlüssel bleiben gültig und werden in der Oberfläche mit Hinweis markiert | | Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben. Je neuem Schlüssel genau ein Recht: `ApiKeysService.create` lehnt mehrere Rechte mit 400 ab (Lesen und Buchen damit immer getrennt); bereits ausgestellte Schlüssel mit mehreren Rechten bleiben gültig und werden in der Oberfläche mit Hinweis markiert; `erp-inventory:read` zählt als Lesen. Nach 20 ungültigen Schlüsseln in 15 min je IP (IPv6: je /64) `429` für weitere ungültige – gültige Schlüssel nie, falsch aufgebaute Tokens zählen nicht; Zähler in `ThrottlerHit`, über alle Pods geteilt (Annahme 30); Warnung beim Start, wenn `TRUST_PROXY` in Produktion fehlt; Logs nur mit Schlüssel-Präfix |
| Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` | | Ausgehende Aufrufe | Webhooks: nie aus der Anfrage heraus gesendet, sondern über die Zustelltabelle `WebhookDelivery` (Warteschlange und Zustellprotokoll), die der Worker abarbeitet; Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, keine Weiterleitungen, 5 s Leerlauf-Timeout und 10 s Frist je Versuch (danach wird die Verbindung abgebrochen); nur eine `2xx`-Antwort gilt als zugestellt, sonst Wiederholung nach der Outbox-Regel (30 s bis 6 h, nach 8 Versuchen `FAILED`); stabile Delivery-ID (`X-ApiCore-Delivery` = `WebhookDelivery.id`, mit gleichem Body über alle Wiederholungen); Signatur über Zeitstempel+ID+Body; Antwortkörper wird nie gelesen, `lastError` nur über `loggableError()` |
| Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies | | Transport/Antworten | Alle API-Antworten tragen `Cache-Control: no-store` und `Pragma: no-cache`; einzige Ausnahme ist die erfolgreiche (200) öffentliche Herkunftsantwort `GET /public/batches/:lot` mit `Cache-Control: public, max-age=300, s-maxage=3600` (ohne Pragma, am Cloudflare-Edge cachebar), Fehlerantworten bleiben `no-store`; `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
| Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf | | Rate-Limits | Throttler vor der Authentifizierung, Zähler in der UNLOGGED-Tabelle `ThrottlerHit` und damit über alle Pods geteilt (eine Upsert-Anweisung, ausschließlich DB-Uhr `now()`; 100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` (IPv6 je /64); bei DB-Fehler schlägt die Anfrage fehl (fail-closed) – nur die Liveness `GET /health` ist ausgenommen, damit ein DB-Ausfall nicht alle Pods neu startet; abgelaufene Zeilen räumt der Job `cleanup` auf |
@@ -0,0 +1,3 @@
-- ERP-Export /bewegungen sortiert und blättert nach (createdAt, id).
-- CreateIndex
CREATE INDEX "BatchMovement_createdAt_id_idx" ON "BatchMovement"("createdAt", "id");
+2
View File
@@ -1210,6 +1210,8 @@ model BatchMovement {
@@index([productionBatchId]) @@index([productionBatchId])
@@index([rawMaterialLotId]) @@index([rawMaterialLotId])
@@index([shopRequestId]) @@index([shopRequestId])
// ERP-Export /bewegungen: sortiert und blättert nach (createdAt, id).
@@index([createdAt, id])
} }
enum ShopStockRequestKind { enum ShopStockRequestKind {
+23 -2
View File
@@ -6,16 +6,37 @@ import { PermissionKey } from '../auth/permissions/permission-catalog';
* only ever gets what a machine integration needs. shop-sync:write lets the * only ever gets what a machine integration needs. shop-sync:write lets the
* shop book its own sales and returns - nothing else - and every booking is * shop book its own sales and returns - nothing else - and every booking is
* idempotent and kept in the ledger, so a leaked key cannot erase history. * idempotent and kept in the ledger, so a leaked key cannot erase history.
* Removing an entry here also takes it away from existing keys (see * erp-inventory:read lets ERPNext read the inventory mirror (articles,
* ApiKeysService.authenticate). * batches with remaining stock, stock movements, harvest totals) - read
* only, no people, no notes. Removing an entry here also takes it away from
* existing keys (see ApiKeysService.authenticate).
*/ */
export const API_KEY_SCOPES = [ export const API_KEY_SCOPES = [
'shop-sync:read', 'shop-sync:read',
'shop-sync:write', 'shop-sync:write',
'erp-inventory:read',
] as const satisfies readonly PermissionKey[]; ] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number]; export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
/**
* Scopes that book (change data). Every other scope only reads. A new key
* never mixes both kinds: a leaked read key cannot book, a leaked booking
* key cannot read (ARCHITECTURE.md, Annahme 14).
*/
export const API_KEY_WRITE_SCOPES = [
'shop-sync:write',
] as const satisfies readonly ApiKeyScope[];
export function isApiKeyScope(value: string): value is ApiKeyScope { export function isApiKeyScope(value: string): value is ApiKeyScope {
return (API_KEY_SCOPES as readonly string[]).includes(value); return (API_KEY_SCOPES as readonly string[]).includes(value);
} }
function isWriteScope(scope: string): boolean {
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
}
/** True if the scopes hold at least one reading and one booking right. */
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
}
@@ -1,12 +1,17 @@
import { Global, Module } from '@nestjs/common'; import { Global, Module } from '@nestjs/common';
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
import { ApiKeysController } from './api-keys.controller'; import { ApiKeysController } from './api-keys.controller';
import { ApiKeysService } from './api-keys.service'; import { ApiKeysService } from './api-keys.service';
import { FailedAttemptLimiter } from './failed-attempt-limiter';
// Global: the app-wide auth guards need ApiKeysService to recognise keys. // Global: the app-wide auth guards need ApiKeysService to recognise keys.
// ThrottlerStorageModule: the failure counters share the rate-limit table,
// so the limit holds across all api pods.
@Global() @Global()
@Module({ @Module({
imports: [ThrottlerStorageModule],
controllers: [ApiKeysController], controllers: [ApiKeysController],
providers: [ApiKeysService], providers: [ApiKeysService, FailedAttemptLimiter],
exports: [ApiKeysService], exports: [ApiKeysService],
}) })
export class ApiKeysModule {} export class ApiKeysModule {}
@@ -1,8 +1,265 @@
import { BadRequestException } from '@nestjs/common'; import {
import { ApiKeysService } from './api-keys.service'; BadRequestException,
ForbiddenException,
HttpException,
Logger,
} from '@nestjs/common';
import { createHash } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
function storedKey(overrides: Record<string, unknown> = {}) {
return {
id: 'key-1',
name: 'ERPNext',
prefix: 'ack_abcd1234',
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
scopes: ['erp-inventory:read'],
createdByUserId: 'user-1',
createdAt: new Date('2026-10-01T00:00:00.000Z'),
lastUsedAt: null,
lastUsedIp: null,
allowedIps: [],
expiresAt: null,
revokedAt: null,
...overrides,
};
}
/**
* Stand-in for the shared counter table with the same counting rule as
* PrismaThrottlerStorage within one window: count up to the limit, the hit
* after it blocks, a blocked source keeps its count. Time and the database
* itself are covered by the e2e tests.
*/
function inMemoryStorage(): PrismaThrottlerStorage {
const rows = new Map<string, { hits: number; blocked: boolean }>();
return {
increment: (
key: string,
ttl: number,
limit: number,
blockDuration: number,
throttlerName: string,
) => {
const id = `${throttlerName}|${key}`;
const row = rows.get(id) ?? { hits: 0, blocked: false };
const next = row.blocked
? row
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
rows.set(id, next);
return Promise.resolve({
totalHits: next.hits,
timeToExpire: ttl / 1000,
isBlocked: next.blocked,
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
});
},
} as unknown as PrismaThrottlerStorage;
}
describe('ApiKeysService.authenticate', () => {
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
let service: ApiKeysService;
let warn: jest.SpyInstance;
beforeEach(() => {
prisma = {
apiKey: {
findUnique: jest.fn().mockResolvedValue(null),
update: jest.fn().mockResolvedValue({}),
},
};
service = new ApiKeysService(
prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService,
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
);
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
});
afterEach(() => {
warn.mockRestore();
});
function loggedText(): string {
return JSON.stringify(warn.mock.calls);
}
it('sucht den Schlüssel nur über seinen Hash', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
});
});
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
null,
);
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
});
it('lehnt einen unbekannten Schlüssel ab', async () => {
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
});
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('revoked');
});
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
);
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
null,
);
expect(loggedText()).toContain('expired');
});
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date() }),
);
await service.authenticate(TOKEN, '203.0.113.7');
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
const text = loggedText();
expect(text).not.toContain(TOKEN);
expect(text).not.toContain(OTHER_TOKEN);
expect(text).not.toContain('A'.repeat(43));
expect(text).not.toContain('B'.repeat(43));
expect(text).toContain('ack_abcd1234');
});
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
);
const principal = await service.authenticate(TOKEN, '203.0.113.7');
expect(principal?.scopes).toEqual(['erp-inventory:read']);
});
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ allowedIps: ['198.51.100.0/24'] }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toBeInstanceOf(ForbiddenException);
});
async function failFrom(ip: string, times: number): Promise<void> {
for (let i = 0; i < times; i++) {
// Unbekannter, aber richtig aufgebauter Schlüssel.
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
}
}
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
await expect(blocked).rejects.toBeInstanceOf(HttpException);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
expect(loggedText()).toContain('203.0.113.7');
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
prisma.apiKey.findUnique.mockResolvedValueOnce(
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
);
await expect(
service.authenticate(TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
// Andere Adressen sind nicht betroffen.
await expect(
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
).resolves.toBe(null);
});
it('meldet das Erreichen der Grenze einmal im Log', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
const limitMessages = warn.mock.calls.filter(([message]) =>
String(message).includes('get 429'),
);
expect(limitMessages).toHaveLength(1);
});
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).rejects.toMatchObject({ status: 429 });
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
id: 'key-1',
scopes: ['erp-inventory:read'],
});
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
null,
);
}
// Weder gesperrt noch eine Datenbankabfrage.
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
await expect(
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
).resolves.toBe(null);
});
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await service.authenticate(
OTHER_TOKEN,
`2001:db8:1:2::${i.toString(16)}`,
);
}
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
).rejects.toMatchObject({ status: 429 });
// Ein anderes /64 ist frei.
await expect(
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
).resolves.toBe(null);
});
});
describe('ApiKeysService.create', () => { describe('ApiKeysService.create', () => {
let service: ApiKeysService; let service: ApiKeysService;
@@ -19,12 +276,15 @@ describe('ApiKeysService.create', () => {
const permissionResolver = { const permissionResolver = {
getPermissions: jest getPermissions: jest
.fn() .fn()
.mockResolvedValue(new Set(['shop-sync:read', 'shop-sync:write'])), .mockResolvedValue(
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
),
}; };
service = new ApiKeysService( service = new ApiKeysService(
prisma as unknown as PrismaService, prisma as unknown as PrismaService,
{ record: jest.fn() } as unknown as AuditLogService, { record: jest.fn() } as unknown as AuditLogService,
permissionResolver as unknown as PermissionResolverService, permissionResolver as unknown as PermissionResolverService,
new FailedAttemptLimiter(inMemoryStorage()),
); );
}); });
@@ -45,7 +305,46 @@ describe('ApiKeysService.create', () => {
expect(prisma.apiKey.create).not.toHaveBeenCalled(); expect(prisma.apiKey.create).not.toHaveBeenCalled();
}); });
it.each(['shop-sync:read', 'shop-sync:write'])( it('refuses the read-only ERP scope together with booking', async () => {
await expect(
service.create(
{
name: 'ERPNext',
scopes: ['erp-inventory:read', 'shop-sync:write'],
},
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('refuses a new key with more than one scope, even if all of them read', async () => {
await expect(
service.create(
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
'user-1',
),
).rejects.toThrow(
new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
),
);
expect(prisma.apiKey.create).not.toHaveBeenCalled();
});
it('treats the same scope given twice as one', async () => {
const created = await service.create(
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
'user-1',
);
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
});
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
'still creates a key with only %s', 'still creates a key with only %s',
async (scope) => { async (scope) => {
const created = await service.create( const created = await service.create(
@@ -56,3 +355,11 @@ describe('ApiKeysService.create', () => {
}, },
); );
}); });
describe('extractApiKeyToken', () => {
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
expect(extractApiKeyToken(undefined)).toBeNull();
});
});
@@ -1,6 +1,8 @@
import { import {
BadRequestException, BadRequestException,
ForbiddenException, ForbiddenException,
HttpException,
HttpStatus,
Injectable, Injectable,
Logger, Logger,
NotFoundException, NotFoundException,
@@ -10,10 +12,19 @@ import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service'; import { AuditLogService } from '../audit/audit-log.service';
import { ApiKey } from '../../generated/prisma'; import { ApiKey } from '../../generated/prisma';
import { CreateApiKeyDto } from './dto/create-api-key.dto'; import { CreateApiKeyDto } from './dto/create-api-key.dto';
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes'; import {
ApiKeyScope,
combinesReadAndWrite,
isApiKeyScope,
} from './api-key-scopes';
import { UpdateApiKeyDto } from './dto/update-api-key.dto'; import { UpdateApiKeyDto } from './dto/update-api-key.dto';
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist'; import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
import {
API_KEY_FAILURE_LIMIT,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
function normalizeAllowlist(entries: readonly string[] | undefined): string[] { function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
return [ return [
@@ -31,6 +42,11 @@ const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
// lastUsedAt is informational; writing it on every request would turn each // lastUsedAt is informational; writing it on every request would turn each
// read into a write for no benefit. // read into a write for no benefit.
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000; const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
// "ack_" plus the 8-character prefix: identifies a key in logs without
// revealing its secret part.
const LOGGABLE_PREFIX_LENGTH = 12;
type RejectReason = 'unknown' | 'revoked' | 'expired';
/** What a request authenticated by an API key may do - it is not a user. */ /** What a request authenticated by an API key may do - it is not a user. */
export interface ApiKeyPrincipal { export interface ApiKeyPrincipal {
@@ -73,6 +89,8 @@ export class ApiKeysService {
private readonly prisma: PrismaService, private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService, private readonly auditLog: AuditLogService,
private readonly permissionResolver: PermissionResolverService, private readonly permissionResolver: PermissionResolverService,
// Counters shared by all api pods (ThrottlerHit), not process memory.
private readonly failures: FailedAttemptLimiter,
) {} ) {}
async findAll(): Promise<ApiKeySummary[]> { async findAll(): Promise<ApiKeySummary[]> {
@@ -86,16 +104,19 @@ export class ApiKeysService {
dto: CreateApiKeyDto, dto: CreateApiKeyDto,
actingUserId: string, actingUserId: string,
): Promise<CreatedApiKey> { ): Promise<CreatedApiKey> {
// Reading and booking are separate keys: a leaked read key cannot book, // One scope per new key: a leaked read key cannot book, a leaked booking
// a leaked booking key cannot read. Existing combined keys stay valid. // key cannot read, and each connected system gets its own key.
if ( // Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
dto.scopes.includes('shop-sync:read') && if (combinesReadAndWrite(dto.scopes)) {
dto.scopes.includes('shop-sync:write')
) {
throw new BadRequestException( throw new BadRequestException(
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen', 'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
); );
} }
if (new Set(dto.scopes).size > 1) {
throw new BadRequestException(
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
);
}
// Nobody hands a machine more than they may do themselves. // Nobody hands a machine more than they may do themselves.
const callerPermissions = const callerPermissions =
await this.permissionResolver.getPermissions(actingUserId); await this.permissionResolver.getPermissions(actingUserId);
@@ -187,6 +208,13 @@ export class ApiKeysService {
/** /**
* Resolves a presented key to what it may do, or null for anything that * Resolves a presented key to what it may do, or null for anything that
* must not pass: malformed, unknown, revoked or expired. * must not pass: malformed, unknown, revoked or expired.
*
* The key is found by the SHA-256 of the presented token through a unique
* index - never by comparing secrets byte by byte, so response timing says
* nothing about the stored key. Order matters: the lookup comes first, and
* only an invalid key counts against (or is stopped by) the failure limit
* of the client network. A malformed token is not a guess at a key and is
* refused without lookup and without counting.
*/ */
async authenticate( async authenticate(
token: string, token: string,
@@ -199,13 +227,53 @@ export class ApiKeysService {
where: { keyHash: hashToken(token) }, where: { keyHash: hashToken(token) },
}); });
const now = new Date(); const now = new Date();
if ( const reason = rejectReason(apiKey, now);
!apiKey || if (reason === null && apiKey) {
apiKey.revokedAt || return this.accept(apiKey, clientIp, now);
(apiKey.expiresAt && apiKey.expiresAt <= now)
) {
return null;
} }
await this.recordRejection(token, reason ?? 'unknown', clientIp);
return null;
}
/** Counts an invalid key; 429 once the client network is over the limit. */
private async recordRejection(
token: string,
reason: RejectReason,
clientIp: string | undefined,
): Promise<void> {
// Counted per client network like the throttler (IPv6 /64), so rotating
// addresses inside one's own prefix does not reset the budget.
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
const verdict = await this.failures.recordFailure(subject);
if (verdict.blocked) {
// Same body shape as the built-in 4xx exceptions (JSON object, not a
// bare string), so clients can read `message` uniformly.
throw new HttpException(
{
statusCode: HttpStatus.TOO_MANY_REQUESTS,
error: 'Too Many Requests',
message:
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
},
HttpStatus.TOO_MANY_REQUESTS,
);
}
// Only the public prefix is ever logged, never the key itself.
this.logger.warn(
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
);
if (verdict.limitReached) {
this.logger.warn(
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
);
}
}
private async accept(
apiKey: ApiKey,
clientIp: string | undefined,
now: Date,
): Promise<ApiKeyPrincipal> {
if (!isIpAllowed(clientIp, apiKey.allowedIps)) { if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
// The key itself is right: say so, so the admin finds the cause - a // The key itself is right: say so, so the admin finds the cause - a
// thief learns nothing new, they already hold the key. // thief learns nothing new, they already hold the key.
@@ -232,6 +300,13 @@ export class ApiKeysService {
} }
} }
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
if (!apiKey) return 'unknown';
if (apiKey.revokedAt) return 'revoked';
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
return null;
}
/** The key from an `Authorization: Bearer ack_…` header, or null. */ /** The key from an `Authorization: Bearer ack_…` header, or null. */
export function extractApiKeyToken( export function extractApiKeyToken(
authorization: string | undefined, authorization: string | undefined,
@@ -0,0 +1,91 @@
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
import {
API_KEY_FAILURE_LIMIT,
API_KEY_FAILURE_THROTTLER,
FailedAttemptLimiter,
} from './failed-attempt-limiter';
describe('FailedAttemptLimiter', () => {
const increment = jest.fn();
const limiter = new FailedAttemptLimiter({
increment,
} as unknown as PrismaThrottlerStorage);
function record(totalHits: number, isBlocked: boolean) {
return {
totalHits,
timeToExpire: 600,
isBlocked,
timeToBlockExpire: isBlocked ? 900 : 0,
};
}
beforeEach(() => {
increment.mockReset();
increment.mockResolvedValue(record(1, false));
});
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
await limiter.recordFailure('203.0.113.7');
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
expect(increment).toHaveBeenCalledTimes(1);
expect(increment).toHaveBeenCalledWith(
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_LIMIT.maxFailures,
API_KEY_FAILURE_LIMIT.windowMs,
API_KEY_FAILURE_THROTTLER,
);
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
});
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
expect(API_KEY_FAILURE_LIMIT).toEqual({
maxFailures: 20,
windowMs: 15 * 60 * 1000,
});
});
it('sperrt nicht unterhalb der Grenze', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: false,
});
});
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: false,
limitReached: true,
});
});
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
increment.mockResolvedValueOnce(
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
);
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
blocked: true,
limitReached: false,
});
});
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
increment.mockRejectedValueOnce(new Error('db down'));
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
'db down',
);
});
});
@@ -0,0 +1,57 @@
import { Injectable } from '@nestjs/common';
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
/**
* Unknown, revoked or expired keys per client network before that network
* gets 429 for further invalid keys - the same budget the ERP export
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
* misconfigured client; a valid key is never refused by it. Otherwise
* anyone able to send requests from (or forge) a shared address - the
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
* holds 256 random bits, so letting it through costs nothing.
*/
export const API_KEY_FAILURE_LIMIT = {
maxFailures: 20,
windowMs: 15 * 60 * 1000,
} as const;
/**
* Throttler name and key prefix of these counters in "ThrottlerHit", so
* they never mix with the request rate limits stored in the same table.
*/
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
export interface FailedAttemptVerdict {
/** The source is over the limit: refuse the request with 429. */
blocked: boolean;
/** This failure is the one that reached the limit (once per window). */
limitReached: boolean;
}
/**
* Counts invalid API keys per source (client network) in the shared,
* database-backed rate-limit storage, so the limit holds across all api
* pods. One upsert per failure, every time comparison on the database
* clock (PrismaThrottlerStorage). The window starts with the first failure;
* the failure after the limit blocks the source for one window length.
* A database error propagates: the request fails instead of passing.
*/
@Injectable()
export class FailedAttemptLimiter {
constructor(private readonly storage: PrismaThrottlerStorage) {}
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
const record = await this.storage.increment(
`${API_KEY_FAILURE_THROTTLER}:${source}`,
windowMs,
maxFailures,
windowMs,
API_KEY_FAILURE_THROTTLER,
);
return {
blocked: record.isBlocked,
limitReached: !record.isBlocked && record.totalHits === maxFailures,
};
}
}
+53
View File
@@ -65,3 +65,56 @@ describe('configureApp trust proxy logging', () => {
log.mockRestore(); log.mockRestore();
}); });
}); });
describe('configureApp trust proxy warning', () => {
const originalProxy = process.env.TRUST_PROXY;
const originalEnv = process.env.NODE_ENV;
afterEach(() => {
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
else process.env.TRUST_PROXY = originalProxy;
if (originalEnv === undefined) delete process.env.NODE_ENV;
else process.env.NODE_ENV = originalEnv;
});
function configure(): jest.SpyInstance {
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
jest.spyOn(Logger.prototype, 'log').mockImplementation();
const app = {
set: jest.fn(),
useBodyParser: jest.fn(),
use: jest.fn(),
useGlobalFilters: jest.fn(),
enableCors: jest.fn(),
setGlobalPrefix: jest.fn(),
enableVersioning: jest.fn(),
useGlobalPipes: jest.fn(),
};
configureApp(app as unknown as NestExpressApplication);
return warn;
}
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
const calls = warn.mock.calls as unknown[][];
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
}
afterEach(() => jest.restoreAllMocks());
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
process.env.NODE_ENV = 'production';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(1);
});
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
process.env.NODE_ENV = 'production';
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
it('warnt nicht in der Entwicklung', () => {
process.env.NODE_ENV = 'development';
delete process.env.TRUST_PROXY;
expect(trustProxyWarnings(configure())).toHaveLength(0);
});
});
+11 -3
View File
@@ -50,9 +50,17 @@ export function configureApp(app: NestExpressApplication): void {
// to work correctly rather than rate-limiting all traffic together. // to work correctly rather than rate-limiting all traffic together.
const trustProxy = trustProxySetting(); const trustProxy = trustProxySetting();
app.set('trust proxy', trustProxy); app.set('trust proxy', trustProxy);
new Logger('Bootstrap').log( const bootstrapLogger = new Logger('Bootstrap');
describeTrustProxy(trustProxy, !!process.env.TRUST_PROXY?.trim()), const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
); bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
// proxy, so rate limits, API-key failure limits and IP allowlists act
// on a shared address instead of the client.
bootstrapLogger.warn(
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml',
);
}
// No ETags: they let caches and proxies revalidate (and keep) personal // No ETags: they let caches and proxies revalidate (and keep) personal
// responses that are meant to be no-store. // responses that are meant to be no-store.
app.set('etag', false); app.set('etag', false);
+2
View File
@@ -51,6 +51,7 @@ import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module'; import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module'; import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module'; import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module'; import { ApiKeysModule } from './api-keys/api-keys.module';
import { SystemModule } from './system/system.module'; import { SystemModule } from './system/system.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
@@ -116,6 +117,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
SiteSettingsModule, SiteSettingsModule,
BackupModule, BackupModule,
ShopSyncModule, ShopSyncModule,
ErpExportModule,
ApiKeysModule, ApiKeysModule,
SystemModule, SystemModule,
], ],
@@ -370,6 +370,15 @@ export const PERMISSION_CATALOG = [
], ],
3500, 3500,
), ),
// Like shop-sync: a narrow read-only allowlist for one machine consumer
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
// which return descriptions and full records.
...group(
'ERP-Anbindung',
'erp-inventory',
[['read', 'Inventar-Spiegel für ERPNext lesen']],
3600,
),
] as const satisfies readonly PermissionCatalogEntry[]; ] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -0,0 +1,110 @@
import { plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import {
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
MAX_PAGE_SIZE,
} from './erp-query.dto';
async function errorsOf<T extends object>(
cls: new () => T,
query: Record<string, string>,
): Promise<string[]> {
const errors = await validate(plainToInstance(cls, query), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('ERP-Abfrageparameter', () => {
it('nimmt eine leere Abfrage an', async () => {
expect(await errorsOf(ErpPageQueryDto, {})).toEqual([]);
expect(await errorsOf(ErpMovementsQueryDto, {})).toEqual([]);
expect(await errorsOf(ErpHarvestsQueryDto, {})).toEqual([]);
});
it('wandelt limit in eine Zahl um', () => {
const dto = plainToInstance(ErpPageQueryDto, { limit: '25' });
expect(dto.limit).toBe(25);
});
it.each(['0', '-1', String(MAX_PAGE_SIZE + 1), '2.5', 'viele'])(
'lehnt limit=%s ab',
async (limit) => {
expect(await errorsOf(ErpPageQueryDto, { limit })).toEqual(['limit']);
},
);
it('nimmt limit an der Obergrenze an', async () => {
expect(
await errorsOf(ErpPageQueryDto, { limit: String(MAX_PAGE_SIZE) }),
).toEqual([]);
});
it.each(["abc'def", 'a b', 'a'.repeat(513), 'abc=='])(
'lehnt den Cursor %s ab',
async (nach) => {
expect(await errorsOf(ErpPageQueryDto, { nach })).toEqual(['nach']);
},
);
it.each([
'2026-01-01',
'2026-01-01T00:00Z',
'2026-01-01T00:00:00Z',
'2026-01-01T00:00:00.123Z',
'2026-01-01T01:00:00+01:00',
'2025-12-31T22:30:00.5-02:30',
'2000-01-01',
'2100-12-31T23:59:59Z',
])('nimmt seit=%s an', async (seit) => {
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual([]);
});
it.each([
'gestern',
'2026-13-01',
'01.01.2026',
// Andere ISO-8601-Formen: führten zu 500 oder wurden falsch gelesen.
'2026-W01',
'20260101',
'-2026-01-01',
'+002026-01-01',
'2026-001',
// Uhrzeit ohne Zone wäre Ortszeit des Servers.
'2026-01-01T00:00',
'2026-01-01T24:00',
'2026-01-01T24:00Z',
'2026-01-01T23:60Z',
'2026-01-01T23:59:60Z',
'2026-01-01T00:00:00+24:00',
'2026-01-01 00:00:00Z',
'2026-02-30',
// Gleiche Jahresgrenzen wie der Cursor: sonst 500 in der Datenbank.
'1999-12-31',
'2101-01-01',
'2100-12-31T23:00:00-02:00',
'9999-12-31T00:00:00Z',
])('lehnt seit=%s ab', async (seit) => {
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual(['seit']);
});
it.each(['1899', '2101', '2026.5', 'dieses'])(
'lehnt jahr=%s ab',
async (jahr) => {
expect(await errorsOf(ErpHarvestsQueryDto, { jahr })).toEqual(['jahr']);
},
);
it('wandelt jahr in eine Zahl um', () => {
expect(plainToInstance(ErpHarvestsQueryDto, { jahr: '2026' }).jahr).toBe(
2026,
);
});
it('lehnt unbekannte Parameter ab', async () => {
expect(await errorsOf(ErpPageQueryDto, { seite: '2' })).toEqual(['seite']);
});
});
@@ -0,0 +1,86 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import {
IsInt,
IsOptional,
IsString,
Matches,
Max,
MaxLength,
Min,
ValidateBy,
} from 'class-validator';
import {
ERP_MAX_YEAR,
ERP_MIN_YEAR,
parseSeit,
SEIT_PATTERN,
} from '../erp-time';
export const DEFAULT_PAGE_SIZE = 100;
export const MAX_PAGE_SIZE = 500;
const MAX_CURSOR_LENGTH = 512;
/** Seitenweises Lesen: `nach` = nextCursor der vorigen Seite, `limit` = Seitengröße. */
export class ErpPageQueryDto {
@ApiPropertyOptional({
description:
'nextCursor der vorigen Antwort, unverändert. Ohne Angabe beginnt die Liste von vorn.',
})
@IsOptional()
@IsString()
@MaxLength(MAX_CURSOR_LENGTH)
@Matches(/^[A-Za-z0-9_-]+$/, {
message: 'nach muss ein Cursor aus einer vorigen Antwort sein',
})
nach?: string;
@ApiPropertyOptional({
description: `Einträge je Seite, 1 bis ${MAX_PAGE_SIZE}`,
default: DEFAULT_PAGE_SIZE,
minimum: 1,
maximum: MAX_PAGE_SIZE,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(MAX_PAGE_SIZE)
limit?: number;
}
export class ErpMovementsQueryDto extends ErpPageQueryDto {
@ApiPropertyOptional({
description: `Nur Bewegungen ab diesem Zeitpunkt (einschließlich): JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm, Jahr ${ERP_MIN_YEAR}–${ERP_MAX_YEAR}. Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".`,
example: '2026-01-01T00:00:00Z',
})
@IsOptional()
@IsString()
@Matches(SEIT_PATTERN, {
message:
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
})
@ValidateBy({
name: 'isErpSeit',
validator: {
validate: (value: unknown) =>
typeof value === 'string' && parseSeit(value) !== null,
defaultMessage: () =>
`seit muss ein gültiger Zeitpunkt in den Jahren ${ERP_MIN_YEAR} bis ${ERP_MAX_YEAR} sein`,
},
})
seit?: string;
}
export class ErpHarvestsQueryDto {
@ApiPropertyOptional({
description: 'Nur dieses Kalenderjahr; ohne Angabe alle Jahre',
example: 2026,
})
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1900)
@Max(2100)
jahr?: number;
}
@@ -0,0 +1,93 @@
import { BadRequestException } from '@nestjs/common';
import { decodeCursor, encodeCursor } from './erp-cursor';
function raw(value: unknown): string {
return Buffer.from(JSON.stringify(value)).toString('base64url');
}
describe('ERP-Cursor', () => {
it('liefert jeden Cursor unverändert zurück (Rundreise)', () => {
const product = { k: 'produkte', id: 'cmabc123' } as const;
const batch = { k: 'chargen', t: 'P', id: 'cmdef456' } as const;
const movement = {
k: 'bewegungen',
ts: '2026-10-01T08:15:30.123Z',
id: 'cmghi789',
} as const;
expect(decodeCursor(encodeCursor(product), 'produkte')).toEqual(product);
expect(decodeCursor(encodeCursor(batch), 'chargen')).toEqual(batch);
expect(decodeCursor(encodeCursor(movement), 'bewegungen')).toEqual(
movement,
);
});
it('ist URL-sicher (base64url ohne Auffüllzeichen)', () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T08:15:30.123Z',
id: 'cmghi789',
});
expect(cursor).toMatch(/^[A-Za-z0-9_-]+$/);
});
it('lehnt einen Cursor eines anderen Endpunkts ab', () => {
const productCursor = encodeCursor({ k: 'produkte', id: 'cmabc123' });
expect(() => decodeCursor(productCursor, 'bewegungen')).toThrow(
BadRequestException,
);
});
it.each([
['kein base64/JSON', 'produkte', 'nicht-json'],
['JSON, aber kein Objekt', 'produkte', raw([1, 2])],
['fehlende id', 'produkte', raw({ k: 'produkte' })],
[
'id mit fremden Zeichen',
'produkte',
raw({ k: 'produkte', id: "x' OR 1" }),
],
['id zu lang', 'produkte', raw({ k: 'produkte', id: 'a'.repeat(65) })],
['zusätzliche Felder', 'produkte', raw({ k: 'produkte', id: 'c1', x: 1 })],
[
'unbekannte Chargenart',
'chargen',
raw({ k: 'chargen', t: 'X', id: 'c1' }),
],
[
'ungültiger Zeitstempel',
'bewegungen',
raw({ k: 'bewegungen', ts: 'gestern', id: 'c1' }),
],
[
'Zeitstempel nicht kanonisch',
'bewegungen',
raw({ k: 'bewegungen', ts: '2026-10-01', id: 'c1' }),
],
[
'Jahr jenseits des Datenbankbereichs',
'bewegungen',
raw({ k: 'bewegungen', ts: '+275760-09-13T00:00:00.000Z', id: 'c1' }),
],
[
'Jahr vor 2000',
'bewegungen',
raw({ k: 'bewegungen', ts: '1999-12-31T23:59:59.999Z', id: 'c1' }),
],
[
'Jahr nach 2100',
'bewegungen',
raw({ k: 'bewegungen', ts: '2101-01-01T00:00:00.000Z', id: 'c1' }),
],
] as const)('lehnt ab: %s', (_label, kind, cursor) => {
expect(() => decodeCursor(cursor, kind)).toThrow(BadRequestException);
});
it.each(['2000-01-01T00:00:00.000Z', '2100-12-31T23:59:59.999Z'])(
'nimmt den Rand des erlaubten Bereichs an: %s',
(ts) => {
const cursor = encodeCursor({ k: 'bewegungen', ts, id: 'c1' });
expect(decodeCursor(cursor, 'bewegungen').ts).toBe(ts);
},
);
});
@@ -0,0 +1,87 @@
import { BadRequestException } from '@nestjs/common';
import { isPlausibleErpDate } from './erp-time';
/**
* Blätter-Cursor des ERP-Exports: die Position hinter dem letzten gelieferten
* Eintrag, als base64url-kodiertes JSON. Für den Aufrufer undurchsichtig - er
* reicht ihn nur unverändert als `nach` zurück.
*
* Jeder Cursor nennt seinen Endpunkt (`k`): ein Produkt-Cursor an den
* Bewegungen wäre sonst stillschweigend eine falsche Position. Was hier
* herauskommt, landet in einer Datenbankabfrage, deshalb wird jedes Feld
* streng geprüft statt nur gecastet.
*/
export type ErpCursor =
| { k: 'produkte'; id: string }
| { k: 'chargen'; t: 'B' | 'P'; id: string }
| { k: 'bewegungen'; ts: string; id: string };
export type ErpCursorKind = ErpCursor['k'];
type CursorOf<K extends ErpCursorKind> = Extract<ErpCursor, { k: K }>;
// cuid-artige Kennungen; großzügig, aber ohne Zeichen, die in einer Abfrage
// etwas anderes bedeuten könnten.
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const FIELDS: Record<ErpCursorKind, readonly string[]> = {
produkte: ['id', 'k'],
chargen: ['id', 'k', 't'],
bewegungen: ['id', 'k', 'ts'],
};
function invalid(): BadRequestException {
return new BadRequestException(
'Ungültiger Cursor - bitte nextCursor der vorigen Seite unverändert als "nach" übergeben',
);
}
export function encodeCursor(cursor: ErpCursor): string {
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
}
function parse(raw: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
throw invalid();
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalid();
}
return value as Record<string, unknown>;
}
function isCanonicalTimestamp(value: unknown): value is string {
if (typeof value !== 'string') return false;
// Nur die eigene Form (toISOString) und nur plausible Jahre (erp-time.ts).
const date = new Date(value);
return isPlausibleErpDate(date) && date.toISOString() === value;
}
/** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */
export function decodeCursor<K extends ErpCursorKind>(
raw: string,
kind: K,
): CursorOf<K> {
const value = parse(raw);
const keys = Object.keys(value).sort();
const expected = FIELDS[kind];
if (
value.k !== kind ||
keys.length !== expected.length ||
keys.some((key, index) => key !== expected[index]) ||
typeof value.id !== 'string' ||
!ID_PATTERN.test(value.id)
) {
throw invalid();
}
if (kind === 'chargen' && value.t !== 'B' && value.t !== 'P') {
throw invalid();
}
if (kind === 'bewegungen' && !isCanonicalTimestamp(value.ts)) {
throw invalid();
}
return value as CursorOf<K>;
}
@@ -0,0 +1,82 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiForbiddenResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import {
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
} from './dto/erp-query.dto';
import {
ErpBatch,
ErpExportService,
ErpHarvestYears,
ErpMovementPage,
ErpPage,
ErpProduct,
} from './erp-export.service';
/**
* Inventar-Spiegel für ERPNext, nur lesend. Vertrag mit Feldern, Einheiten,
* Paginierung und Fehlercodes: docs/erp-inventar-export.md.
*/
@ApiTags('erp-export')
@ApiBearerAuth()
@ApiUnauthorizedResponse({
description: 'Kein, unbekannter, widerrufener oder abgelaufener Schlüssel',
})
@ApiForbiddenResponse({
description:
'Schlüssel ohne erp-inventory:read oder von einer nicht freigegebenen IP-Adresse',
})
@ApiTooManyRequestsResponse({
description: 'Zu viele Anfragen oder Fehlversuche von dieser Adresse',
})
@ApiBadRequestResponse({ description: 'Ungültiger Parameter oder Cursor' })
@RequirePermission('erp-inventory:read')
@Controller('export/inventar')
export class ErpExportController {
constructor(private readonly erpExport: ErpExportService) {}
@ApiOperation({ summary: 'Artikel (ApiCore-Produkte), nach id sortiert' })
@ApiOkResponse({ description: 'Eine Seite Artikel' })
@Get('produkte')
products(@Query() query: ErpPageQueryDto): Promise<ErpPage<ErpProduct>> {
return this.erpExport.products(query);
}
@ApiOperation({
summary:
'Chargen (Abfüllungen, dann Herstellungschargen) mit Ausgangsmenge und Rest',
})
@ApiOkResponse({ description: 'Eine Seite Chargen' })
@Get('chargen')
batches(@Query() query: ErpPageQueryDto): Promise<ErpPage<ErpBatch>> {
return this.erpExport.batches(query);
}
@ApiOperation({
summary:
'Bestandsbewegungen (Shop-Verkauf, Retoure, Entnahme mit Grund, Inventur), älteste zuerst',
})
@ApiOkResponse({ description: 'Eine Seite Bewegungen' })
@Get('bewegungen')
movements(@Query() query: ErpMovementsQueryDto): Promise<ErpMovementPage> {
return this.erpExport.movements(query);
}
@ApiOperation({ summary: 'Geerntete kg (netto) je Kalenderjahr' })
@ApiOkResponse({ description: 'Erntemengen je Jahr' })
@Get('ernten')
harvests(@Query() query: ErpHarvestsQueryDto): Promise<ErpHarvestYears> {
return this.erpExport.harvestYears(query);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ErpExportController } from './erp-export.controller';
import { ErpExportService } from './erp-export.service';
@Module({
imports: [StockModule],
controllers: [ErpExportController],
providers: [ErpExportService],
})
export class ErpExportModule {}
@@ -0,0 +1,572 @@
import { BadRequestException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { StockService } from '../stock/stock.service';
import { decodeCursor, encodeCursor } from './erp-cursor';
import {
ERP_INVENTORY_CONTRACT_VERSION,
ErpExportService,
MOVEMENT_SETTLE_MS,
} from './erp-export.service';
type FindManyArgs = {
where?: Record<string, unknown>;
select?: Record<string, unknown>;
orderBy?: unknown;
take?: number;
};
const NOW = new Date('2026-10-12T02:00:00.000Z');
// Die Datenbank-Uhr geht hier bewusst anders als die Uhr des Pods (NOW):
// die Wartezeit muss von der Datenbank kommen.
const DB_SETTLED_UNTIL = new Date('2026-10-12T01:52:30.000Z');
function bottlingRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
lotNumber: `L-${id}`,
productId: 'prod-honig',
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
jarCount: 60,
jarSize: 500,
honeyBatch: { honeyType: 'Sommertracht' },
...overrides,
};
}
function productionRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
lotNumber: `K-${id}`,
productId: 'prod-kerze',
date: new Date('2026-09-01T00:00:00.000Z'),
bestBeforeDate: null,
quantity: 20,
...overrides,
};
}
function movementRow(
id: string,
createdAt: string,
overrides: Record<string, unknown> = {},
) {
return {
id,
createdAt: new Date(createdAt),
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -2,
bottlingBatchId: 'b1',
productionBatchId: null,
bottlingBatch: { lotNumber: 'L-b1', productId: 'prod-honig' },
productionBatch: null,
...overrides,
};
}
describe('ErpExportService', () => {
let prisma: {
product: { findMany: jest.Mock };
bottlingBatch: { findMany: jest.Mock };
productionBatch: { findMany: jest.Mock };
batchMovement: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
$queryRaw: jest.Mock;
};
let stock: {
remainingByBottling: jest.Mock;
remainingByProductionBatch: jest.Mock;
};
let service: ErpExportService;
beforeEach(() => {
jest.useFakeTimers().setSystemTime(NOW);
prisma = {
product: { findMany: jest.fn().mockResolvedValue([]) },
bottlingBatch: { findMany: jest.fn().mockResolvedValue([]) },
productionBatch: { findMany: jest.fn().mockResolvedValue([]) },
batchMovement: { findMany: jest.fn().mockResolvedValue([]) },
harvest: { findMany: jest.fn().mockResolvedValue([]) },
$queryRaw: jest
.fn()
.mockResolvedValue([{ settledUntil: DB_SETTLED_UNTIL }]),
};
stock = {
remainingByBottling: jest.fn().mockResolvedValue(new Map()),
remainingByProductionBatch: jest.fn().mockResolvedValue(new Map()),
};
service = new ErpExportService(
prisma as unknown as PrismaService,
stock as unknown as StockService,
);
});
afterEach(() => {
jest.useRealTimers();
});
function lastArgs(mock: jest.Mock): FindManyArgs {
const calls = mock.mock.calls as FindManyArgs[][];
return calls[calls.length - 1][0];
}
describe('products', () => {
it('liest nur die freigegebenen Felder, stabil nach id sortiert', async () => {
prisma.product.findMany.mockResolvedValueOnce([
{
id: 'p1',
name: 'Sommertracht 500 g',
sku: 'WH-500',
kind: 'BOTTLED_HONEY',
shopCategory: 'HONIG',
status: 'ACTIVE',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
updatedAt: new Date('2026-09-01T00:00:00.000Z'),
},
]);
const page = await service.products({});
const args = lastArgs(prisma.product.findMany);
expect(Object.keys(args.select ?? {}).sort()).toEqual(
[
'id',
'kind',
'name',
'packagingSize',
'salesUnit',
'shopCategory',
'sku',
'status',
'updatedAt',
'weight',
].sort(),
);
// Die Beschreibung ist in ApiCore eine interne Notiz.
expect(args.select).not.toHaveProperty('description');
expect(args.orderBy).toEqual({ id: 'asc' });
expect(args.take).toBe(101);
expect(page).toMatchObject({
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
hasMore: false,
items: [{ id: 'p1', name: 'Sommertracht 500 g', sku: 'WH-500' }],
});
expect(decodeCursor(page.nextCursor!, 'produkte')).toEqual({
k: 'produkte',
id: 'p1',
});
});
it('blättert mit Cursor und meldet weitere Seiten', async () => {
prisma.product.findMany.mockResolvedValueOnce([
{ id: 'p3' },
{ id: 'p4' },
{ id: 'p5' },
]);
const page = await service.products({
limit: 2,
nach: encodeCursor({ k: 'produkte', id: 'p2' }),
});
const args = lastArgs(prisma.product.findMany);
expect(args.where).toEqual({ id: { gt: 'p2' } });
expect(args.take).toBe(3);
expect(page.items.map((p) => p.id)).toEqual(['p3', 'p4']);
expect(page.hasMore).toBe(true);
expect(decodeCursor(page.nextCursor!, 'produkte').id).toBe('p4');
});
it('gibt bei leerer Seite den übergebenen Cursor zurück', async () => {
const nach = encodeCursor({ k: 'produkte', id: 'p9' });
const page = await service.products({ nach });
expect(page).toMatchObject({
items: [],
hasMore: false,
nextCursor: nach,
});
});
it('lehnt einen fremden Cursor ab', async () => {
await expect(
service.products({
nach: encodeCursor({ k: 'chargen', t: 'B', id: 'b1' }),
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.product.findMany).not.toHaveBeenCalled();
});
});
describe('batches', () => {
it('liefert Abfüllungen vor Herstellungschargen, mit Rest aus dem Bewegungsbuch', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([
bottlingRow('b1'),
bottlingRow('b2', { productId: null, honeyBatch: { honeyType: null } }),
]);
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k1', { bestBeforeDate: new Date('2027-09-01') }),
]);
stock.remainingByBottling.mockResolvedValueOnce(
new Map([
['b1', 41],
['b2', 0],
]),
);
stock.remainingByProductionBatch.mockResolvedValueOnce(
new Map([['k1', 17]]),
);
const page = await service.batches({ limit: 10 });
expect(page.items).toEqual([
{
id: 'b1',
batchType: 'BOTTLING',
lotNumber: 'L-b1',
productId: 'prod-honig',
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
initialQuantity: 60,
remainingQuantity: 41,
fillWeightGrams: 500,
honeyType: 'Sommertracht',
},
{
id: 'b2',
batchType: 'BOTTLING',
lotNumber: 'L-b2',
productId: null,
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
initialQuantity: 60,
remainingQuantity: 0,
fillWeightGrams: 500,
honeyType: null,
},
{
id: 'k1',
batchType: 'PRODUCTION',
lotNumber: 'K-k1',
productId: 'prod-kerze',
date: new Date('2026-09-01T00:00:00.000Z'),
bestBeforeDate: new Date('2027-09-01'),
initialQuantity: 20,
remainingQuantity: 17,
fillWeightGrams: null,
honeyType: null,
},
]);
expect(stock.remainingByBottling).toHaveBeenCalledWith(['b1', 'b2']);
expect(stock.remainingByProductionBatch).toHaveBeenCalledWith(['k1']);
expect(page.hasMore).toBe(false);
expect(decodeCursor(page.nextCursor!, 'chargen')).toEqual({
k: 'chargen',
t: 'P',
id: 'k1',
});
});
it('liest keine Notizen und keine Benutzer', async () => {
await service.batches({});
for (const mock of [
prisma.bottlingBatch.findMany,
prisma.productionBatch.findMany,
]) {
const select = lastArgs(mock).select ?? {};
expect(select).not.toHaveProperty('notes');
expect(select).not.toHaveProperty('user');
expect(select).not.toHaveProperty('userId');
}
});
it('fragt Herstellungschargen nicht ab, solange die Seite mit Abfüllungen voll ist', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([
bottlingRow('b1'),
bottlingRow('b2'),
bottlingRow('b3'),
]);
const page = await service.batches({ limit: 2 });
expect(prisma.productionBatch.findMany).not.toHaveBeenCalled();
expect(page.items.map((b) => b.id)).toEqual(['b1', 'b2']);
expect(page.hasMore).toBe(true);
expect(decodeCursor(page.nextCursor!, 'chargen')).toMatchObject({
t: 'B',
id: 'b2',
});
});
it('füllt den Rest der Seite mit Herstellungschargen und erkennt weitere', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([bottlingRow('b9')]);
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k1'),
productionRow('k2'),
]);
const page = await service.batches({
limit: 2,
nach: encodeCursor({ k: 'chargen', t: 'B', id: 'b8' }),
});
expect(lastArgs(prisma.bottlingBatch.findMany).where).toEqual({
id: { gt: 'b8' },
});
// Nach den Abfüllungen beginnen die Herstellungschargen von vorn.
expect(lastArgs(prisma.productionBatch.findMany).where).toBeUndefined();
expect(lastArgs(prisma.productionBatch.findMany).take).toBe(2);
expect(page.items.map((b) => b.id)).toEqual(['b9', 'k1']);
expect(page.hasMore).toBe(true);
});
it('setzt hinter einer Herstellungscharge fort, ohne Abfüllungen erneut zu lesen', async () => {
prisma.productionBatch.findMany.mockResolvedValueOnce([
productionRow('k3'),
]);
const page = await service.batches({
nach: encodeCursor({ k: 'chargen', t: 'P', id: 'k2' }),
});
expect(prisma.bottlingBatch.findMany).not.toHaveBeenCalled();
expect(lastArgs(prisma.productionBatch.findMany).where).toEqual({
id: { gt: 'k2' },
});
expect(page.items.map((b) => b.id)).toEqual(['k3']);
});
it('nimmt die Ausgangsmenge als Rest, wenn das Bewegungsbuch nichts liefert', async () => {
prisma.bottlingBatch.findMany.mockResolvedValueOnce([bottlingRow('b1')]);
const page = await service.batches({});
expect(page.items[0].remainingQuantity).toBe(60);
});
});
describe('movements', () => {
const settledUntil = DB_SETTLED_UNTIL;
it('nimmt die Wartezeit von der Datenbank-Uhr, nicht von der Uhr des Pods', async () => {
const page = await service.movements({});
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
const [strings, ...values] = prisma.$queryRaw.mock.calls[0] as [
readonly string[],
...unknown[],
];
expect(strings.join('?')).toContain('now()');
expect(values).toEqual([MOVEMENT_SETTLE_MS]);
expect(values.some((value) => value instanceof Date)).toBe(false);
expect(page.settledUntil).toEqual(DB_SETTLED_UNTIL);
expect(page.settledUntil).not.toEqual(
new Date(NOW.getTime() - MOVEMENT_SETTLE_MS),
);
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({ createdAt: { lte: DB_SETTLED_UNTIL } });
});
it('liest "seit" mit Zone als UTC-Zeitpunkt', async () => {
await service.movements({ seit: '2026-01-01T01:00:00+01:00' });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({
createdAt: {
lte: settledUntil,
gte: new Date('2026-01-01T00:00:00.000Z'),
},
});
});
it('lehnt ein "seit" außerhalb der Vertragsformen ab, ohne die Datenbank zu fragen', async () => {
await expect(
service.movements({ seit: '2026-W01' }),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.batchMovement.findMany).not.toHaveBeenCalled();
});
it('liefert nur Bewegungen von Abfüllungen und Herstellungschargen, die älter als die Wartezeit sind', async () => {
await service.movements({});
const args = lastArgs(prisma.batchMovement.findMany);
expect(args.where).toEqual({
AND: [
{
OR: [
{ bottlingBatchId: { not: null } },
{ productionBatchId: { not: null } },
],
},
{ createdAt: { lte: settledUntil } },
],
});
expect(args.orderBy).toEqual([{ createdAt: 'asc' }, { id: 'asc' }]);
expect(args.take).toBe(101);
});
it('liest weder Notizen noch Benutzer noch Shop-Anfragen', async () => {
await service.movements({});
const select = lastArgs(prisma.batchMovement.findMany).select ?? {};
for (const field of [
'notes',
'user',
'userId',
'apiKeyId',
'shopRequest',
'shopRequestId',
'rawMaterialLotId',
]) {
expect(select).not.toHaveProperty(field);
}
});
it('bildet Bewegungen auf den Vertrag ab', async () => {
prisma.batchMovement.findMany.mockResolvedValueOnce([
movementRow('m1', '2026-10-01T10:00:00.000Z'),
movementRow('m2', '2026-10-01T11:00:00.000Z', {
type: 'SHOP_SALE',
reason: null,
quantity: -3,
bottlingBatchId: null,
productionBatchId: 'k1',
bottlingBatch: null,
productionBatch: { lotNumber: 'K-k1', productId: 'prod-kerze' },
}),
]);
const page = await service.movements({});
expect(page.items).toEqual([
{
id: 'm1',
createdAt: new Date('2026-10-01T10:00:00.000Z'),
batchType: 'BOTTLING',
batchId: 'b1',
lotNumber: 'L-b1',
productId: 'prod-honig',
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -2,
},
{
id: 'm2',
createdAt: new Date('2026-10-01T11:00:00.000Z'),
batchType: 'PRODUCTION',
batchId: 'k1',
lotNumber: 'K-k1',
productId: 'prod-kerze',
type: 'SHOP_SALE',
reason: null,
quantity: -3,
},
]);
expect(page.settledUntil).toEqual(settledUntil);
expect(decodeCursor(page.nextCursor!, 'bewegungen')).toEqual({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
});
});
it('beginnt mit "seit" einschließlich des Zeitpunkts', async () => {
await service.movements({ seit: '2026-01-01T00:00:00.000Z' });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
expect(where.AND[1]).toEqual({
createdAt: {
lte: settledUntil,
gte: new Date('2026-01-01T00:00:00.000Z'),
},
});
});
it('setzt hinter dem Cursor fort (Zeitpunkt, dann id)', async () => {
const nach = encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
});
const page = await service.movements({ nach });
const where = lastArgs(prisma.batchMovement.findMany).where as {
AND: unknown[];
};
const ts = new Date('2026-10-01T11:00:00.000Z');
expect(where.AND[2]).toEqual({
OR: [{ createdAt: { gt: ts } }, { createdAt: ts, id: { gt: 'm2' } }],
});
// Nichts Neues: der Cursor bleibt, der Abruf in der nächsten Nacht setzt dort fort.
expect(page).toMatchObject({
items: [],
hasMore: false,
nextCursor: nach,
});
});
it('meldet weitere Seiten', async () => {
prisma.batchMovement.findMany.mockResolvedValueOnce([
movementRow('m1', '2026-10-01T10:00:00.000Z'),
movementRow('m2', '2026-10-01T10:00:00.000Z'),
]);
const page = await service.movements({ limit: 1 });
expect(page.items.map((m) => m.id)).toEqual(['m1']);
expect(page.hasMore).toBe(true);
});
it('lehnt "seit" und "nach" zusammen ab', async () => {
await expect(
service.movements({
seit: '2026-01-01T00:00:00.000Z',
nach: encodeCursor({
k: 'bewegungen',
ts: '2026-10-01T11:00:00.000Z',
id: 'm2',
}),
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(prisma.batchMovement.findMany).not.toHaveBeenCalled();
});
});
describe('harvestYears', () => {
it('liest für ein Jahr nur Datum und Nettogewicht in den UTC-Jahresgrenzen', async () => {
prisma.harvest.findMany.mockResolvedValueOnce([
{ date: new Date('2026-07-14T00:00:00.000Z'), netWeight: 41 },
{ date: new Date('2026-08-02T00:00:00.000Z'), netWeight: null },
]);
const result = await service.harvestYears({ jahr: 2026 });
const args = lastArgs(prisma.harvest.findMany);
expect(args.select).toEqual({ date: true, netWeight: true });
expect(args.where).toEqual({
date: {
gte: new Date('2026-01-01T00:00:00.000Z'),
lt: new Date('2027-01-01T00:00:00.000Z'),
},
});
expect(result).toEqual({
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: NOW,
years: [
{
year: 2026,
netWeightKg: 41,
harvestCount: 2,
harvestsWithoutNetWeight: 1,
},
],
});
});
it('liefert ohne Jahr alle Jahre', async () => {
await service.harvestYears({});
expect(lastArgs(prisma.harvest.findMany).where).toBeUndefined();
});
});
});
@@ -0,0 +1,446 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { StockService } from '../stock/stock.service';
import {
BatchMovementType,
Prisma,
ProductKind,
ProductStatus,
ShopCategory,
WithdrawalReason,
} from '../../generated/prisma';
import { decodeCursor, encodeCursor, type ErpCursor } from './erp-cursor';
import {
DEFAULT_PAGE_SIZE,
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
} from './dto/erp-query.dto';
import { summarizeHarvestYears, type HarvestYear } from './harvest-years';
import { parseSeit } from './erp-time';
/**
* Erhöht nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in
* der Bedeutung geändert). Neue Felder kommen ohne Versionswechsel dazu; der
* Importer ignoriert Unbekanntes. Vertrag: docs/erp-inventar-export.md.
*/
export const ERP_INVENTORY_CONTRACT_VERSION = 1;
/**
* Bewegungen erscheinen erst, wenn sie so alt sind. Eine Buchung bekommt
* ihren Zeitstempel beim Einfügen, sichtbar wird sie aber erst beim Commit
* ihrer Transaktion. Ohne Wartezeit könnte ein Abruf eine spätere Buchung
* schon sehen und den Cursor über eine frühere, noch offene hinwegschieben -
* die frühere ginge dem Spiegel dann für immer verloren. Die Grenze rechnet
* die Datenbank-Uhr (now()), nicht die Uhr des Pods: mehrere api-Pods geben
* so dieselbe Grenze, auch wenn ihre Uhren auseinanderlaufen.
*/
export const MOVEMENT_SETTLE_MS = 5 * 60 * 1000;
export type ErpBatchType = 'BOTTLING' | 'PRODUCTION';
export interface ErpPage<T> {
contractVersion: number;
generatedAt: Date;
items: T[];
/** Position hinter dem letzten Eintrag; bei leerer Seite der übergebene Cursor. */
nextCursor: string | null;
hasMore: boolean;
}
export interface ErpProduct {
id: string;
name: string;
sku: string | null;
kind: ProductKind;
shopCategory: ShopCategory;
status: ProductStatus;
packagingSize: number | null;
weight: number | null;
salesUnit: string | null;
updatedAt: Date;
}
export interface ErpBatch {
id: string;
batchType: ErpBatchType;
lotNumber: string;
productId: string | null;
/** Abfüll- bzw. Herstelldatum. */
date: Date;
bestBeforeDate: Date | null;
/** Abgefüllte Gläser bzw. hergestellte Stück. */
initialQuantity: number;
/** Ausgangsmenge plus Summe aller Bewegungen; kann negativ sein (zu prüfen). */
remainingQuantity: number;
/** Füllmenge je Glas in Gramm; nur bei Abfüllungen. */
fillWeightGrams: number | null;
/** Sorte der Honigcharge hinter der Abfüllung; nur bei Abfüllungen. */
honeyType: string | null;
}
export interface ErpMovement {
id: string;
createdAt: Date;
batchType: ErpBatchType;
batchId: string;
lotNumber: string;
productId: string | null;
type: BatchMovementType;
reason: WithdrawalReason | null;
/** Stück mit Vorzeichen: minus = aus dem Bestand, plus = zurück. */
quantity: number;
}
export interface ErpMovementPage extends ErpPage<ErpMovement> {
/** Bis hierhin ist die Liste vollständig (jetzt minus Wartezeit). */
settledUntil: Date;
}
export interface ErpHarvestYears {
contractVersion: number;
generatedAt: Date;
years: HarvestYear[];
}
// Feld-Allowlists: nur was hier steht, verlässt ApiCore. Notizen, Benutzer,
// Beschreibungen (in ApiCore interne Notizen) und alles, was später an die
// Modelle kommt, bleiben drin.
const PRODUCT_SELECT = {
id: true,
name: true,
sku: true,
kind: true,
shopCategory: true,
status: true,
packagingSize: true,
weight: true,
salesUnit: true,
updatedAt: true,
} satisfies Prisma.ProductSelect;
const BOTTLING_SELECT = {
id: true,
lotNumber: true,
productId: true,
date: true,
bestBeforeDate: true,
jarCount: true,
jarSize: true,
honeyBatch: { select: { honeyType: true } },
} satisfies Prisma.BottlingBatchSelect;
const PRODUCTION_SELECT = {
id: true,
lotNumber: true,
productId: true,
date: true,
bestBeforeDate: true,
quantity: true,
} satisfies Prisma.ProductionBatchSelect;
const BATCH_REF_SELECT = { select: { lotNumber: true, productId: true } };
const MOVEMENT_SELECT = {
id: true,
createdAt: true,
type: true,
reason: true,
quantity: true,
bottlingBatchId: true,
productionBatchId: true,
bottlingBatch: BATCH_REF_SELECT,
productionBatch: BATCH_REF_SELECT,
} satisfies Prisma.BatchMovementSelect;
type BottlingRow = Prisma.BottlingBatchGetPayload<{
select: typeof BOTTLING_SELECT;
}>;
type ProductionRow = Prisma.ProductionBatchGetPayload<{
select: typeof PRODUCTION_SELECT;
}>;
type MovementRow = Prisma.BatchMovementGetPayload<{
select: typeof MOVEMENT_SELECT;
}>;
/** Chargen ohne Rest - der kommt nach dem Zuschnitt der Seite dazu. */
type BatchWithoutRemaining = Omit<ErpBatch, 'remainingQuantity'>;
function toPage<T>(
rows: readonly T[],
limit: number,
cursorOf: (last: T) => ErpCursor,
given: string | undefined,
): Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'> {
const items = rows.slice(0, limit);
const last = items[items.length - 1];
return {
items,
hasMore: rows.length > limit,
nextCursor:
last !== undefined ? encodeCursor(cursorOf(last)) : (given ?? null),
};
}
function fromBottling(row: BottlingRow): BatchWithoutRemaining {
return {
id: row.id,
batchType: 'BOTTLING',
lotNumber: row.lotNumber,
productId: row.productId,
date: row.date,
bestBeforeDate: row.bestBeforeDate,
initialQuantity: row.jarCount,
fillWeightGrams: row.jarSize,
honeyType: row.honeyBatch.honeyType,
};
}
function fromProduction(row: ProductionRow): BatchWithoutRemaining {
return {
id: row.id,
batchType: 'PRODUCTION',
lotNumber: row.lotNumber,
productId: row.productId,
date: row.date,
bestBeforeDate: row.bestBeforeDate,
initialQuantity: row.quantity,
fillWeightGrams: null,
honeyType: null,
};
}
function fromMovement(row: MovementRow): ErpMovement {
const batch = row.bottlingBatch ?? row.productionBatch;
const batchId = row.bottlingBatchId ?? row.productionBatchId;
if (!batch || !batchId) {
// Durch die Abfrage ausgeschlossen (nur Bewegungen mit Charge).
throw new Error(
`Bewegung ${row.id} ohne Abfüllung oder Herstellungscharge`,
);
}
return {
id: row.id,
createdAt: row.createdAt,
batchType: row.bottlingBatchId ? 'BOTTLING' : 'PRODUCTION',
batchId,
lotNumber: batch.lotNumber,
productId: batch.productId,
type: row.type,
reason: row.reason,
quantity: row.quantity,
};
}
function idAfter(id: string | undefined): { id: { gt: string } } | undefined {
return id !== undefined ? { id: { gt: id } } : undefined;
}
/**
* Nur lesender Inventar-Export für ERPNext (Teilprojekt T8 des
* ERPNext-Ausbaus): Artikel, Chargen mit Rest, Bestandsbewegungen und
* Erntemengen. ApiCore bleibt führend; ERPNext spiegelt nur.
*
* Zugang über einen API-Schlüssel mit dem Recht erp-inventory:read, das nur
* diese Endpunkte öffnet. Alle Listen sind stabil sortiert und seitenweise.
*/
@Injectable()
export class ErpExportService {
constructor(
private readonly prisma: PrismaService,
private readonly stock: StockService,
) {}
async products(query: ErpPageQueryDto): Promise<ErpPage<ErpProduct>> {
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'produkte') : null;
const rows = await this.prisma.product.findMany({
where: idAfter(after?.id),
select: PRODUCT_SELECT,
orderBy: { id: 'asc' },
take: limit + 1,
});
return this.envelope(
toPage(
rows,
limit,
(last) => ({ k: 'produkte', id: last.id }),
query.nach,
),
);
}
/** Erst alle Abfüllungen, dann alle Herstellungschargen, je nach id. */
async batches(query: ErpPageQueryDto): Promise<ErpPage<ErpBatch>> {
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'chargen') : null;
const bottlings =
after?.t === 'P'
? []
: await this.prisma.bottlingBatch.findMany({
where: idAfter(after?.id),
select: BOTTLING_SELECT,
orderBy: { id: 'asc' },
take: limit + 1,
});
// Erst wenn die Abfüllungen auf dieser Seite enden, kommen
// Herstellungschargen dazu - eine mehr als Platz ist, um weitere zu erkennen.
const productions =
bottlings.length > limit
? []
: await this.prisma.productionBatch.findMany({
where: idAfter(after?.t === 'P' ? after.id : undefined),
select: PRODUCTION_SELECT,
orderBy: { id: 'asc' },
take: limit - bottlings.length + 1,
});
const page = toPage(
[...bottlings.map(fromBottling), ...productions.map(fromProduction)],
limit,
(last) => ({
k: 'chargen',
t: last.batchType === 'BOTTLING' ? 'B' : 'P',
id: last.id,
}),
query.nach,
);
return this.envelope({
...page,
items: await this.withRemaining(page.items),
});
}
async movements(query: ErpMovementsQueryDto): Promise<ErpMovementPage> {
if (query.seit !== undefined && query.nach !== undefined) {
throw new BadRequestException(
'Entweder "seit" (erster Abruf) oder "nach" (weiterlesen) angeben, nicht beides',
);
}
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'bewegungen') : null;
const since = query.seit !== undefined ? seitDate(query.seit) : undefined;
const settledUntil = await this.settledUntil();
const afterCursor: Prisma.BatchMovementWhereInput[] = after
? [
{
OR: [
{ createdAt: { gt: new Date(after.ts) } },
{ createdAt: new Date(after.ts), id: { gt: after.id } },
],
},
]
: [];
const conditions: Prisma.BatchMovementWhereInput[] = [
// Nur Ware: Rohstoff-Bewegungen (Wachs, Propolis) gehören nicht in den Spiegel.
{
OR: [
{ bottlingBatchId: { not: null } },
{ productionBatchId: { not: null } },
],
},
{
createdAt: {
lte: settledUntil,
...(since !== undefined ? { gte: since } : {}),
},
},
...afterCursor,
];
const rows = await this.prisma.batchMovement.findMany({
where: { AND: conditions },
select: MOVEMENT_SELECT,
orderBy: [{ createdAt: 'asc' }, { id: 'asc' }],
take: limit + 1,
});
const page = toPage(
rows.map(fromMovement),
limit,
(last) => ({
k: 'bewegungen',
ts: last.createdAt.toISOString(),
id: last.id,
}),
query.nach,
);
return { ...this.envelope(page), settledUntil };
}
async harvestYears(query: ErpHarvestsQueryDto): Promise<ErpHarvestYears> {
const year = query.jahr;
const rows = await this.prisma.harvest.findMany({
where:
year !== undefined
? {
date: {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
},
}
: undefined,
select: { date: true, netWeight: true },
});
return {
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: new Date(),
years: summarizeHarvestYears(rows, year),
};
}
/** Bis hierhin ist das Bewegungsbuch vollständig: DB-Uhr minus Wartezeit. */
private async settledUntil(): Promise<Date> {
const [row] = await this.prisma.$queryRaw<{ settledUntil: Date }[]>`
SELECT now() - (${MOVEMENT_SETTLE_MS}::double precision * interval '1 millisecond') AS "settledUntil"
`;
return row.settledUntil;
}
private envelope<T>(
page: Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'>,
): ErpPage<T> {
return {
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: new Date(),
...page,
};
}
private async withRemaining(
batches: readonly BatchWithoutRemaining[],
): Promise<ErpBatch[]> {
const idsOf = (type: ErpBatchType) =>
batches.filter((b) => b.batchType === type).map((b) => b.id);
const bottlingIds = idsOf('BOTTLING');
const productionIds = idsOf('PRODUCTION');
const [bottlingRest, productionRest] = await Promise.all([
bottlingIds.length > 0
? this.stock.remainingByBottling(bottlingIds)
: new Map<string, number>(),
productionIds.length > 0
? this.stock.remainingByProductionBatch(productionIds)
: new Map<string, number>(),
]);
return batches.map((batch) => {
const rest =
batch.batchType === 'BOTTLING' ? bottlingRest : productionRest;
return {
...batch,
remainingQuantity: rest.get(batch.id) ?? batch.initialQuantity,
};
});
}
}
/** "seit" ist im DTO schon geprüft; hier nur noch die Umwandlung, sicherheitshalber mit 400. */
function seitDate(seit: string): Date {
const date = parseSeit(seit);
if (!date) {
throw new BadRequestException(
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
);
}
return date;
}
@@ -0,0 +1,51 @@
import { isPlausibleErpDate, parseSeit } from './erp-time';
describe('parseSeit', () => {
it.each([
['2026-01-01', '2026-01-01T00:00:00.000Z'],
['2026-01-01T00:00Z', '2026-01-01T00:00:00.000Z'],
['2026-01-01T08:15:30Z', '2026-01-01T08:15:30.000Z'],
['2026-01-01T08:15:30.1Z', '2026-01-01T08:15:30.100Z'],
['2026-01-01T08:15:30.123Z', '2026-01-01T08:15:30.123Z'],
['2026-01-01T01:00:00+01:00', '2026-01-01T00:00:00.000Z'],
['2025-12-31T21:30:00-02:30', '2026-01-01T00:00:00.000Z'],
])('liest %s als %s (UTC)', (seit, iso) => {
expect(parseSeit(seit)?.toISOString()).toBe(iso);
});
it.each([
'2026-W01',
'20260101',
'-2026-01-01',
'2026-001',
'2026-01-01T00:00',
'2026-01-01T24:00Z',
'2026-02-29',
'2026-04-31',
'1999-12-31T23:59:59Z',
'2101-01-01',
'2100-12-31T23:00:00-02:00',
])('lehnt %s ab', (seit) => {
expect(parseSeit(seit)).toBeNull();
});
it('kennt Schalttage', () => {
expect(parseSeit('2028-02-29')?.toISOString()).toBe(
'2028-02-29T00:00:00.000Z',
);
});
});
describe('isPlausibleErpDate', () => {
it('lässt nur die Jahre 2000 bis 2100 (UTC) zu', () => {
expect(isPlausibleErpDate(new Date('2000-01-01T00:00:00.000Z'))).toBe(true);
expect(isPlausibleErpDate(new Date('2100-12-31T23:59:59.999Z'))).toBe(true);
expect(isPlausibleErpDate(new Date('1999-12-31T23:59:59.999Z'))).toBe(
false,
);
expect(isPlausibleErpDate(new Date('2101-01-01T00:00:00.000Z'))).toBe(
false,
);
expect(isPlausibleErpDate(new Date(Number.NaN))).toBe(false);
});
});
@@ -0,0 +1,69 @@
/**
* Zeitpunkte, die der ERP-Export aus Anfragen übernimmt (`seit`, Cursor).
* Nur plausible Jahre: JavaScript kennt Jahre bis +275760, die
* Datenbankabfrage nicht - das wäre ein 500 statt eines 400.
*/
export const ERP_MIN_YEAR = 2000;
export const ERP_MAX_YEAR = 2100;
export function isPlausibleErpDate(date: Date): boolean {
if (Number.isNaN(date.getTime())) return false;
const year = date.getUTCFullYear();
return year >= ERP_MIN_YEAR && year <= ERP_MAX_YEAR;
}
/**
* Die einzigen Formen für `seit`: ein Kalendertag (`JJJJ-MM-TT`, gilt als
* Mitternacht UTC) oder ein Zeitpunkt mit ausdrücklicher Zone
* (`JJJJ-MM-TTThh:mm[:ss[.sss]]` mit `Z` oder `±hh:mm`). Andere ISO-8601-
* Formen (Wochen, Ordinaltage, Kurzform ohne Striche, erweiterte Jahre,
* Uhrzeit ohne Zone = Ortszeit des Servers) werden abgelehnt, statt falsch
* gelesen zu werden oder als 500 in der Datenbank zu enden.
*/
export const SEIT_PATTERN =
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|([+-])(\d{2}):(\d{2})))?$/;
const MS_PER_MINUTE = 60_000;
/** Der Zeitpunkt zu `seit`, oder null, wenn Form, Kalender oder Jahr nicht passen. */
export function parseSeit(value: string): Date | null {
const match = SEIT_PATTERN.exec(value);
if (!match) return null;
// Optionale Gruppen ohne Treffer sind undefined.
const groups: (string | undefined)[] = [...match];
const [, y, mo, d, h, mi, s, frac, , sign, oh, om] = groups;
const year = Number(y);
const month = Number(mo) - 1;
const day = Number(d);
const hours = Number(h ?? 0);
const minutes = Number(mi ?? 0);
const seconds = Number(s ?? 0);
const offsetHours = Number(oh ?? 0);
const offsetMinutes = Number(om ?? 0);
if (
hours > 23 ||
minutes > 59 ||
seconds > 59 ||
offsetHours > 23 ||
offsetMinutes > 59
) {
return null;
}
const calendarDay = new Date(Date.UTC(year, month, day));
if (
calendarDay.getUTCFullYear() !== year ||
calendarDay.getUTCMonth() !== month ||
calendarDay.getUTCDate() !== day
) {
return null; // z. B. 2026-02-30
}
const millis = Number((frac ?? '').padEnd(3, '0'));
const offset =
(sign === '-' ? -1 : 1) *
(offsetHours * 60 + offsetMinutes) *
MS_PER_MINUTE;
const date = new Date(
Date.UTC(year, month, day, hours, minutes, seconds, millis) - offset,
);
return isPlausibleErpDate(date) ? date : null;
}
@@ -0,0 +1,96 @@
import { summarizeHarvestYears } from './harvest-years';
function harvest(date: string, netWeight: number | null) {
return { date: new Date(date), netWeight };
}
describe('summarizeHarvestYears', () => {
it('summiert das Nettogewicht je Kalenderjahr (UTC), älteste Jahre zuerst', () => {
const years = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 41),
harvest('2025-06-01T00:00:00.000Z', 12.5),
harvest('2026-05-20T00:00:00.000Z', 18.25),
]);
expect(years).toEqual([
{
year: 2025,
netWeightKg: 12.5,
harvestCount: 1,
harvestsWithoutNetWeight: 0,
},
{
year: 2026,
netWeightKg: 59.25,
harvestCount: 2,
harvestsWithoutNetWeight: 0,
},
]);
});
it('zählt Ernten ohne Nettogewicht mit, rechnet sie aber nicht ein', () => {
const [year] = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 20),
harvest('2026-08-01T00:00:00.000Z', null),
]);
expect(year).toEqual({
year: 2026,
netWeightKg: 20,
harvestCount: 2,
harvestsWithoutNetWeight: 1,
});
});
it('rundet auf Gramm, damit Gleitkomma-Reste nicht im Vertrag landen', () => {
const [year] = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 0.1),
harvest('2026-07-15T00:00:00.000Z', 0.2),
harvest('2026-07-16T00:00:00.000Z', 12.3456),
]);
expect(year.netWeightKg).toBe(12.646);
});
it('ordnet Silvester und Neujahr nach UTC zu', () => {
const years = summarizeHarvestYears([
harvest('2025-12-31T23:59:59.999Z', 1),
harvest('2026-01-01T00:00:00.000Z', 2),
]);
expect(years.map((y) => [y.year, y.netWeightKg])).toEqual([
[2025, 1],
[2026, 2],
]);
});
it('liefert für ein angefragtes Jahr ohne Ernten eine Nullzeile statt nichts', () => {
expect(summarizeHarvestYears([], 2024)).toEqual([
{
year: 2024,
netWeightKg: 0,
harvestCount: 0,
harvestsWithoutNetWeight: 0,
},
]);
});
it('beschränkt sich auf das angefragte Jahr', () => {
const years = summarizeHarvestYears(
[
harvest('2025-06-01T00:00:00.000Z', 5),
harvest('2026-06-01T00:00:00.000Z', 7),
],
2026,
);
expect(years).toEqual([
{
year: 2026,
netWeightKg: 7,
harvestCount: 1,
harvestsWithoutNetWeight: 0,
},
]);
});
it('liefert ohne Ernten und ohne Jahr eine leere Liste', () => {
expect(summarizeHarvestYears([])).toEqual([]);
});
});
@@ -0,0 +1,72 @@
/**
* Geerntete Honigmenge je Kalenderjahr - Grundlage für die Honigkosten je kg
* in ERPNext (Imkerei-Ausgaben des Jahres / geerntete kg).
*
* Gleiche Definition wie die Statistik in ApiCore (Honigertrag je Jahr):
* Summe der Nettogewichte (kg) der Ernten, Jahr aus dem Erntedatum. Das
* Jahr wird in UTC bestimmt, weil Erntedaten als Kalendertag (Mitternacht
* UTC) gespeichert werden. Ernten ohne Nettogewicht zählen nicht zur Menge,
* werden aber ausgewiesen - sonst sähe eine unvollständig erfasste Ernte
* wie ein schlechtes Jahr aus und die Kosten je kg wären zu hoch.
*/
export interface HarvestWeight {
date: Date;
/** Kilogramm; null, wenn nicht erfasst. */
netWeight: number | null;
}
export interface HarvestYear {
year: number;
/** Summe der Nettogewichte in kg, auf Gramm gerundet. */
netWeightKg: number;
harvestCount: number;
harvestsWithoutNetWeight: number;
}
const GRAMS_PER_KG = 1000;
function roundToGrams(kg: number): number {
return Math.round(kg * GRAMS_PER_KG) / GRAMS_PER_KG;
}
function emptyYear(year: number): HarvestYear {
return {
year,
netWeightKg: 0,
harvestCount: 0,
harvestsWithoutNetWeight: 0,
};
}
/**
* Fasst Ernten je Jahr zusammen, älteste Jahre zuerst. Mit `onlyYear` nur
* dieses Jahr, und zwar auch dann, wenn es keine Ernte hat (Nullzeile).
*/
export function summarizeHarvestYears(
harvests: readonly HarvestWeight[],
onlyYear?: number,
): HarvestYear[] {
const byYear = new Map<number, HarvestYear>();
if (onlyYear !== undefined) {
byYear.set(onlyYear, emptyYear(onlyYear));
}
for (const harvest of harvests) {
const year = harvest.date.getUTCFullYear();
if (onlyYear !== undefined && year !== onlyYear) continue;
const current = byYear.get(year) ?? emptyYear(year);
byYear.set(year, {
year,
netWeightKg: current.netWeightKg + (harvest.netWeight ?? 0),
harvestCount: current.harvestCount + 1,
harvestsWithoutNetWeight:
current.harvestsWithoutNetWeight + (harvest.netWeight === null ? 1 : 0),
});
}
return [...byYear.values()]
.sort((a, b) => a.year - b.year)
.map((entry) => ({
...entry,
netWeightKg: roundToGrams(entry.netWeightKg),
}));
}
@@ -103,6 +103,14 @@ describe('API keys (e2e)', () => {
expect(JSON.stringify(response.body)).toContain('getrennte Schlüssel'); expect(JSON.stringify(response.body)).toContain('getrennte Schlüssel');
}); });
it('refuses a new key with more than one scope', async () => {
const response = await createKey({
name: `Two reads ${suffix}`,
scopes: ['shop-sync:read', 'erp-inventory:read'],
}).expect(400);
expect(JSON.stringify(response.body)).toContain('nur eine Berechtigung');
});
it('lets a key read exactly what its scopes allow', async () => { it('lets a key read exactly what its scopes allow', async () => {
const { token } = ( const { token } = (
await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] }) await createKey({ name: `Scope ${suffix}`, scopes: ['shop-sync:read'] })
+765
View File
@@ -0,0 +1,765 @@
import { randomUUID } from 'node:crypto';
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { encodeCursor } from '../src/erp-export/erp-cursor';
import { API_KEY_FAILURE_LIMIT } from '../src/api-keys/failed-attempt-limiter';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface CreatedKeyBody {
apiKey: { id: string };
token: string;
}
interface PageBody<T> {
contractVersion: number;
generatedAt: string;
items: T[];
nextCursor: string | null;
hasMore: boolean;
settledUntil?: string;
}
type Item = Record<string, unknown> & { id: string };
interface HarvestYearsBody {
contractVersion: number;
years: {
year: number;
netWeightKg: number;
harvestCount: number;
harvestsWithoutNetWeight: number;
}[];
}
const BASE = '/api/v1/export/inventar';
const ENDPOINTS = ['produkte', 'chargen', 'bewegungen', 'ernten'] as const;
/**
* Inventar-Spiegel für ERPNext (T8): nur lesend, nur mit erp-inventory:read,
* nur freigegebene Felder, stabil und seitenweise. Der ERPNext-Importer wird
* nach docs/erp-inventar-export.md gebaut und verlässt sich auf diese Form.
*/
describe('ERP-Inventar-Export (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
// Eigene Zähler-Menge dieses Laufs; eine zweite Instanz mit derselben
// Menge spielt einen zweiten api-Pod.
const throttlerNamespace = randomUUID();
const password = 'SuperSecret123!';
let adminToken: string;
let readonlyToken: string;
let erpKey: string;
let shopKey: string;
let honeyProductId: string;
let candleProductId: string;
let bottlingId: string;
let productionBatchId: string;
// Laufeigener Zeitraum weit in der Vergangenheit: so finden sich die
// Bewegungen dieses Laufs zwischen denen anderer Läufe und Tests.
const movementBase = Date.UTC(2001, 0, 1) + (Date.now() % 1_000_000_000);
const at = (seconds: number) => new Date(movementBase + seconds * 1000);
const movementIds: Record<string, string> = {};
const server = () => app.getHttpServer();
// Jeder Test tritt als eigener Client auf (X-Forwarded-For, im Test wird
// ein Proxy-Hop vertraut): diese Datei stellt mehr Anfragen, als der
// globale Throttler einer einzelnen Adresse je Minute erlaubt.
let clientCounter = 0;
let clientIp = '198.51.100.1';
beforeEach(() => {
clientCounter += 1;
clientIp = `198.51.100.${clientCounter}`;
});
function get(path: string, token: string, ip = clientIp) {
return request(server())
.get(`${BASE}/${path}`)
.set('Authorization', `Bearer ${token}`)
.set('X-Forwarded-For', ip);
}
async function createKey(scopes: string[]): Promise<CreatedKeyBody> {
const response = await request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: `ERP-Test ${suffix}`, scopes })
.expect(201);
return response.body as CreatedKeyBody;
}
async function allItems(path: string): Promise<Item[]> {
const items: Item[] = [];
let nach: string | null = null;
for (let page = 0; page < 20; page++) {
const query = nach ? `?limit=500&nach=${nach}` : '?limit=500';
const body = (await get(`${path}${query}`, erpKey).expect(200))
.body as PageBody<Item>;
items.push(...body.items);
if (!body.hasMore) return items;
nach = body.nextCursor;
}
throw new Error('zu viele Seiten');
}
beforeAll(async () => {
app = await createTestApp({ throttlerNamespace });
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const owner = await prisma.user.create({
data: {
email: `erp-admin-${suffix}@example.com`,
passwordHash,
name: 'Erika Imkerin',
role: { connect: { key: 'ADMIN' } },
},
});
await prisma.user.create({
data: {
email: `erp-ro-${suffix}@example.com`,
passwordHash,
name: 'Nur Lesen',
role: { connect: { key: 'READONLY' } },
},
});
adminToken = (await login(app, `erp-admin-${suffix}@example.com`, password))
.accessToken;
readonlyToken = (await login(app, `erp-ro-${suffix}@example.com`, password))
.accessToken;
erpKey = (await createKey(['erp-inventory:read'])).token;
// Lesen und Buchen sind getrennte Schlüssel; zum Gegenprüfen reicht Lesen.
shopKey = (await createKey(['shop-sync:read'])).token;
const apiary = await prisma.apiary.create({
data: { name: `Waldrand ${suffix}`, notes: 'Schlüssel unter dem Stein' },
});
const harvest = await prisma.harvest.create({
data: {
apiaryId: apiary.id,
userId: owner.id,
date: new Date('2026-07-14T00:00:00.000Z'),
honeyType: 'Sommertracht',
netWeight: 41,
notes: 'Volk 7 schwach',
},
});
const extraction = await prisma.extraction.create({
data: {
date: new Date('2026-07-20T00:00:00.000Z'),
userId: owner.id,
harvests: { create: [{ harvestId: harvest.id }] },
},
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `HB-${suffix}`,
extractionId: extraction.id,
honeyType: 'Sommertracht',
amount: 50,
},
});
honeyProductId = (
await prisma.product.create({
data: {
name: `Sommertracht 500 g ${suffix}`,
sku: `ERP-${suffix}`,
description: 'Interne Notiz: Lieferant Gläser Huber',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
},
})
).id;
candleProductId = (
await prisma.product.create({
data: {
name: `Bienenwachskerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
})
).id;
bottlingId = (
await prisma.bottlingBatch.create({
data: {
honeyBatchId: honeyBatch.id,
productId: honeyProductId,
userId: owner.id,
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
jarSize: 500,
jarCount: 60,
totalAmount: 30,
lotNumber: `L-ERP-${suffix}`,
},
})
).id;
productionBatchId = (
await prisma.productionBatch.create({
data: {
productId: candleProductId,
userId: owner.id,
lotNumber: `K-ERP-${suffix}`,
date: new Date('2026-09-01T00:00:00.000Z'),
quantity: 20,
notes: 'Docht von Firma Meier',
},
})
).id;
const rawLot = await prisma.rawMaterialLot.create({
data: {
kind: 'BEESWAX',
lotNumber: `W-ERP-${suffix}`,
date: new Date('2026-07-20T00:00:00.000Z'),
quantity: 1000,
},
});
const book = async (
key: string,
data: Parameters<PrismaService['batchMovement']['create']>[0]['data'],
) => {
movementIds[key] = (await prisma.batchMovement.create({ data })).id;
};
const personal = {
notes: 'an Nachbar Müller verschenkt',
userId: owner.id,
};
await book('hofladen', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -6,
createdAt: at(1),
...personal,
});
await book('eigenbedarf', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'EIGENBEDARF',
quantity: -2,
createdAt: at(2),
...personal,
});
await book('verkauf', {
bottlingBatchId: bottlingId,
type: 'SHOP_SALE',
quantity: -3,
createdAt: at(2),
});
await book('bruch', {
productionBatchId,
type: 'WITHDRAWAL',
reason: 'BRUCH',
quantity: -1,
createdAt: at(3),
...personal,
});
await book('korrektur', {
bottlingBatchId: bottlingId,
type: 'CORRECTION',
quantity: 1,
createdAt: at(4),
...personal,
});
await book('rohstoff', {
rawMaterialLotId: rawLot.id,
type: 'WITHDRAWAL',
reason: 'SONSTIGES',
quantity: -10,
createdAt: at(5),
});
// Gerade eben gebucht: noch innerhalb der Wartezeit.
await book('frisch', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'PROBE',
quantity: -1,
});
});
afterAll(async () => {
await prisma.throttlerHit.deleteMany({
where: { key: { startsWith: `${throttlerNamespace}:` } },
});
await app.close();
});
describe('Zugang', () => {
it.each(ENDPOINTS)('%s ist mit dem ERP-Schlüssel lesbar', async (path) => {
await get(path, erpKey).expect(200);
});
it.each(ENDPOINTS)('%s verlangt einen Schlüssel (401)', async (path) => {
await request(server()).get(`${BASE}/${path}`).expect(401);
});
it('lehnt fehlerhafte und unbekannte Schlüssel ab (401)', async () => {
await get('produkte', 'ack_nope').expect(401);
await get('produkte', `ack_abcd1234_${'Z'.repeat(43)}`).expect(401);
});
it.each(ENDPOINTS)(
'%s bleibt dem Shop-Schlüssel verschlossen (403)',
async (path) => {
await get(path, shopKey).expect(403);
},
);
it('verweigert Benutzern ohne das Recht den Export (403)', async () => {
await get('produkte', readonlyToken).expect(403);
});
it('öffnet dem ERP-Schlüssel nichts außer dem Export', async () => {
const auth = { Authorization: `Bearer ${erpKey}` };
for (const path of [
'/api/v1/shop-sync',
'/api/v1/shop-sync/stock',
'/api/v1/products',
'/api/v1/harvests',
'/api/v1/bottling-batches',
'/api/v1/export/colonies.csv',
'/api/v1/users',
'/api/v1/auth/me',
]) {
await request(server()).get(path).set(auth).expect(403);
}
await request(server())
.post('/api/v1/shop-sync/movements')
.set(auth)
.send({ requests: [] })
.expect(403);
});
it('sperrt einen widerrufenen Schlüssel sofort (401)', async () => {
const created = await createKey(['erp-inventory:read']);
await get('produkte', created.token).expect(200);
await request(server())
.post(`/api/v1/api-keys/${created.apiKey.id}/revoke`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
await get('produkte', created.token).expect(401);
});
});
describe('Produkte', () => {
it('liefert nur die freigegebenen Felder, ohne Beschreibung', async () => {
const items = await allItems('produkte');
const honey = items.find((p) => p.id === honeyProductId);
const candle = items.find((p) => p.id === candleProductId);
expect(Object.keys(honey ?? {}).sort()).toEqual(
[
'id',
'kind',
'name',
'packagingSize',
'salesUnit',
'shopCategory',
'sku',
'status',
'updatedAt',
'weight',
].sort(),
);
expect(honey).toMatchObject({
name: `Sommertracht 500 g ${suffix}`,
sku: `ERP-${suffix}`,
kind: 'BOTTLED_HONEY',
shopCategory: 'HONIG',
status: 'ACTIVE',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
});
expect(candle).toMatchObject({
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
});
expect(JSON.stringify(items)).not.toContain('Lieferant Gläser Huber');
});
it('blättert stabil nach id', async () => {
const first = (await get('produkte?limit=1', erpKey).expect(200))
.body as PageBody<Item>;
expect(first.contractVersion).toBe(1);
expect(first.items).toHaveLength(1);
expect(first.hasMore).toBe(true);
const second = (
await get(`produkte?limit=1&nach=${first.nextCursor}`, erpKey).expect(
200,
)
).body as PageBody<Item>;
expect(second.items).toHaveLength(1);
expect(second.items[0].id > first.items[0].id).toBe(true);
});
});
describe('Chargen', () => {
it('liefert Abfüllung und Herstellungscharge mit Rest aus dem Bewegungsbuch', async () => {
const items = await allItems('chargen');
const bottling = items.find((b) => b.id === bottlingId);
const production = items.find((b) => b.id === productionBatchId);
expect(bottling).toEqual({
id: bottlingId,
batchType: 'BOTTLING',
lotNumber: `L-ERP-${suffix}`,
productId: honeyProductId,
date: '2026-08-01T00:00:00.000Z',
bestBeforeDate: '2028-08-01T00:00:00.000Z',
initialQuantity: 60,
// 60 - 6 Hofladen - 2 Eigenbedarf - 3 Shop + 1 Inventur - 1 Probe
remainingQuantity: 49,
fillWeightGrams: 500,
honeyType: 'Sommertracht',
});
expect(production).toEqual({
id: productionBatchId,
batchType: 'PRODUCTION',
lotNumber: `K-ERP-${suffix}`,
productId: candleProductId,
date: '2026-09-01T00:00:00.000Z',
bestBeforeDate: null,
initialQuantity: 20,
remainingQuantity: 19,
fillWeightGrams: null,
honeyType: null,
});
// Abfüllungen vor Herstellungschargen.
const kinds = items.map((b) => b.batchType);
expect(kinds.lastIndexOf('BOTTLING')).toBeLessThan(
kinds.indexOf('PRODUCTION'),
);
const raw = JSON.stringify(items);
expect(raw).not.toContain('Docht von Firma Meier');
expect(raw).not.toContain('Erika Imkerin');
// Rohstoff-Chargen sind keine Ware.
expect(raw).not.toContain(`W-ERP-${suffix}`);
});
it('blättert ohne Lücken und Dubletten', async () => {
const all = (await allItems('chargen')).map((b) => b.id);
const limit = Math.max(1, Math.ceil(all.length / 3));
const seen: string[] = [];
let nach: string | null = null;
for (let page = 0; page < 20; page++) {
const query: string = nach
? `?limit=${limit}&nach=${nach}`
: `?limit=${limit}`;
const body = (await get(`chargen${query}`, erpKey).expect(200))
.body as PageBody<Item>;
expect(body.items.length).toBeLessThanOrEqual(limit);
seen.push(...body.items.map((b) => b.id));
nach = body.nextCursor;
if (!body.hasMore) break;
}
expect(new Set(seen).size).toBe(seen.length);
expect(seen).toEqual(
expect.arrayContaining([bottlingId, productionBatchId]),
);
// Andere Testdateien legen parallel Chargen an oder löschen welche:
// verglichen wird die Reihenfolge der Chargen, die beide Abrufe sahen.
const inBoth = new Set(all.filter((id) => seen.includes(id)));
expect(seen.filter((id) => inBoth.has(id))).toEqual(
all.filter((id) => inBoth.has(id)),
);
});
});
describe('Bewegungen', () => {
async function myMovements(
seit = at(0).toISOString(),
limit = 2,
): Promise<{ items: Item[]; cursor: string }> {
const mine = new Set(Object.values(movementIds));
const found: Item[] = [];
let query = `bewegungen?limit=${limit}&seit=${encodeURIComponent(seit)}`;
for (let page = 0; page < 40; page++) {
const body = (await get(query, erpKey).expect(200))
.body as PageBody<Item>;
found.push(...body.items.filter((m) => mine.has(m.id)));
const last = body.items[body.items.length - 1];
const passedMine =
last !== undefined &&
new Date(last.createdAt as string).getTime() > at(5).getTime();
if (!body.hasMore || passedMine) {
return { items: found, cursor: body.nextCursor! };
}
query = `bewegungen?limit=${limit}&nach=${body.nextCursor}`;
}
throw new Error('zu viele Seiten');
}
it('liefert Entnahmen mit Grund, Shop-Verkäufe und Inventur in stabiler Reihenfolge', async () => {
const { items } = await myMovements();
// Zeitpunkt, dann id: eigenbedarf und verkauf haben denselben Zeitpunkt.
const sameTime = [movementIds.eigenbedarf, movementIds.verkauf].sort();
expect(items.map((m) => m.id)).toEqual([
movementIds.hofladen,
...sameTime,
movementIds.bruch,
movementIds.korrektur,
]);
expect(items.find((m) => m.id === movementIds.hofladen)).toEqual({
id: movementIds.hofladen,
createdAt: at(1).toISOString(),
batchType: 'BOTTLING',
batchId: bottlingId,
lotNumber: `L-ERP-${suffix}`,
productId: honeyProductId,
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -6,
});
expect(items.find((m) => m.id === movementIds.bruch)).toMatchObject({
batchType: 'PRODUCTION',
batchId: productionBatchId,
productId: candleProductId,
reason: 'BRUCH',
quantity: -1,
});
expect(items.find((m) => m.id === movementIds.verkauf)).toMatchObject({
type: 'SHOP_SALE',
reason: null,
quantity: -3,
});
expect(items.find((m) => m.id === movementIds.korrektur)).toMatchObject({
type: 'CORRECTION',
quantity: 1,
});
});
it('lässt Rohstoffe, Notizen, Benutzer und noch offene Buchungen weg', async () => {
const { items } = await myMovements();
const ids = items.map((m) => m.id);
expect(ids).not.toContain(movementIds.rohstoff);
expect(ids).not.toContain(movementIds.frisch);
const raw = JSON.stringify(items);
expect(raw).not.toContain('Müller');
expect(raw).not.toContain('Erika');
expect(raw).not.toContain('userId');
});
it('nimmt seit als Kalendertag (Mitternacht UTC) und mit Zone an', async () => {
const expected = (await myMovements()).items.map((m) => m.id);
expect(expected.length).toBeGreaterThan(0);
const day = at(0).toISOString().slice(0, 10);
const fromDay = await myMovements(day, 500);
expect(fromDay.items.map((m) => m.id)).toEqual(expected);
// Derselbe Zeitpunkt wie at(0), mit +01:00 geschrieben.
const local = new Date(at(0).getTime() + 60 * 60 * 1000)
.toISOString()
.slice(0, 23);
const fromOffset = await myMovements(`${local}+01:00`);
expect(fromOffset.items.map((m) => m.id)).toEqual(expected);
});
it('meldet, bis wann die Liste vollständig ist', async () => {
const before = Date.now();
const body = (await get('bewegungen?limit=1', erpKey).expect(200))
.body as PageBody<Item>;
const settled = new Date(body.settledUntil!).getTime();
expect(settled).toBeLessThanOrEqual(before - 4 * 60 * 1000);
expect(settled).toBeGreaterThan(before - 6 * 60 * 1000);
});
it('setzt mit dem letzten Cursor fort, ohne etwas doppelt zu liefern', async () => {
const { cursor } = await myMovements();
const body = (
await get(`bewegungen?limit=500&nach=${cursor}`, erpKey).expect(200)
).body as PageBody<Item>;
const mine = new Set(Object.values(movementIds));
// Nur Bewegungen hinter dem Cursor - die eigenen lagen davor.
expect(body.items.filter((m) => mine.has(m.id))).toEqual([]);
});
});
describe('Parameter', () => {
it.each([
['produkte?limit=0'],
['produkte?limit=501'],
['produkte?limit=zwei'],
['produkte?nach=kaputt!'],
['produkte?seite=2'],
['chargen?nach=bm90LWpzb24'],
['bewegungen?seit=gestern'],
// Andere ISO-8601-Formen: früher 500 (Woche, Kurzform) oder falsch gelesen.
['bewegungen?seit=2026-W01'],
['bewegungen?seit=20260101'],
['bewegungen?seit=-2026-01-01'],
['bewegungen?seit=2026-001'],
['bewegungen?seit=2026-01-01T24:00'],
['bewegungen?seit=9999-12-31T00:00:00Z'],
['ernten?jahr=1800'],
['ernten?jahr=dieses'],
])('lehnt %s ab (400)', async (path) => {
await get(path, erpKey).expect(400);
});
it('lehnt einen Cursor eines anderen Endpunkts ab (400)', async () => {
const productCursor = encodeCursor({ k: 'produkte', id: honeyProductId });
await get(`bewegungen?nach=${productCursor}`, erpKey).expect(400);
});
it('lehnt einen Bewegungs-Cursor mit unmöglichem Jahr ab (400 statt 500)', async () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: '+275760-09-13T00:00:00.000Z',
id: movementIds.hofladen,
});
await get(`bewegungen?nach=${cursor}`, erpKey).expect(400);
});
it('verträgt ein fernes seit innerhalb der Jahresgrenzen (leere Seite statt Fehler)', async () => {
const body = (
await get('bewegungen?seit=2100-12-31T00:00:00Z', erpKey).expect(200)
).body as PageBody<Item>;
expect(body.items).toEqual([]);
});
it('lehnt seit und nach zusammen ab (400)', async () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: at(1).toISOString(),
id: movementIds.hofladen,
});
await get(
`bewegungen?seit=${at(0).toISOString()}&nach=${cursor}`,
erpKey,
).expect(400);
});
});
describe('Ernten', () => {
it('summiert das Nettogewicht eines Jahres und weist Ernten ohne Gewicht aus', async () => {
const year = 2003;
const read = async () =>
(
(await get(`ernten?jahr=${year}`, erpKey).expect(200))
.body as HarvestYearsBody
).years;
const before = await read();
expect(before).toHaveLength(1);
const owner = await prisma.user.findFirstOrThrow({
where: { email: `erp-admin-${suffix}@example.com` },
});
const apiary = await prisma.apiary.create({
data: { name: `Ernte-Stand ${suffix}` },
});
for (const [day, netWeight] of [
['2003-06-01', 12.5],
['2003-07-15', 7.25],
['2003-08-30', null],
['2004-01-01', 99],
] as const) {
await prisma.harvest.create({
data: {
apiaryId: apiary.id,
userId: owner.id,
date: new Date(`${day}T00:00:00.000Z`),
netWeight,
},
});
}
const [after] = await read();
expect(after.year).toBe(year);
expect(after.netWeightKg - before[0].netWeightKg).toBeCloseTo(19.75, 3);
expect(after.harvestCount - before[0].harvestCount).toBe(3);
expect(
after.harvestsWithoutNetWeight - before[0].harvestsWithoutNetWeight,
).toBe(1);
});
it('liefert ohne Jahr alle Jahre aufsteigend, ohne Standorte', async () => {
const body = (await get('ernten', erpKey).expect(200))
.body as HarvestYearsBody;
const years = body.years.map((y) => y.year);
expect(years).toEqual([...years].sort((a, b) => a - b));
expect(years).toContain(2026);
const raw = JSON.stringify(body);
expect(raw).not.toContain('Waldrand');
expect(raw).not.toContain('Volk 7');
});
});
describe('Fehlversuche', () => {
it('sperrt nach zu vielen unbekannten Schlüsseln weitere ungültige mit 429, andere Adressen nicht', async () => {
const attacker = '203.0.113.50';
const wrong = `ack_abcd1234_${'Y'.repeat(43)}`;
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await get('produkte', wrong, attacker).expect(401);
}
const blocked = await get('produkte', wrong, attacker).expect(429);
expect(blocked.body).toMatchObject({
statusCode: 429,
message: expect.stringContaining('Fehlversuche') as string,
});
await get('produkte', wrong, '203.0.113.51').expect(401);
});
it('lässt einen gültigen Schlüssel auch von einer gesperrten Adresse durch', async () => {
// Sonst könnte jeder, der die gemeinsame Adresse (Cluster-Ausgang,
// Proxy) nutzt oder fälscht, ERPNext oder den Shop aussperren.
const shared = '203.0.113.60';
const wrong = `ack_abcd1234_${'X'.repeat(43)}`;
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await get('produkte', wrong, shared).expect(401);
}
await get('produkte', wrong, shared).expect(429);
await get('produkte', erpKey, shared).expect(200);
await request(server())
.get('/api/v1/shop-sync/stock')
.set('Authorization', `Bearer ${shopKey}`)
.set('X-Forwarded-For', shared)
.expect(200);
});
it('zählt Fehlversuche über alle API-Pods gemeinsam', async () => {
// Die API läuft mit mehreren Pods: ein Angreifer darf sein Budget
// nicht vervielfachen, indem der Load-Balancer ihn verteilt.
const secondPod = await createTestApp({ throttlerNamespace });
try {
const ip = '203.0.113.80';
const wrong = `ack_abcd1234_${'V'.repeat(43)}`;
const via = (pod: INestApplication<App>) =>
request(pod.getHttpServer())
.get(`${BASE}/produkte`)
.set('Authorization', `Bearer ${wrong}`)
.set('X-Forwarded-For', ip);
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await via(i % 2 ? secondPod : app).expect(401);
}
await via(app).expect(429);
await via(secondPod).expect(429);
// Ein gültiger Schlüssel kommt auf beiden Pods weiter durch.
await request(secondPod.getHttpServer())
.get(`${BASE}/produkte`)
.set('Authorization', `Bearer ${erpKey}`)
.set('X-Forwarded-For', ip)
.expect(200);
} finally {
await secondPod.close();
}
});
it('zählt fehlerhaft aufgebaute Schlüssel nicht', async () => {
const ip = '203.0.113.70';
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures + 5; i++) {
await get('produkte', 'ack_x', ip).expect(401);
}
await get('produkte', `ack_abcd1234_${'W'.repeat(43)}`, ip).expect(401);
});
});
});
@@ -3,8 +3,14 @@ import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ApiKeysTab } from "./api-keys-tab"; import { ApiKeysTab } from "./api-keys-tab";
import { createApiKey, fetchApiKeys } from "@/lib/api-keys/api"; import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types"; import type { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
// Rechte des angemeldeten Nutzers; je Test anpassbar.
let ownPermissions: string[] = [];
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: () => ({ hasPermission: (permission: string) => ownPermissions.includes(permission) }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } })); vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/lib/api-keys/api", () => ({ vi.mock("@/lib/api-keys/api", () => ({
@@ -16,7 +22,7 @@ vi.mock("@/lib/api-keys/api", () => ({
const HINT = /Dieser Schlüssel darf lesen und buchen/; const HINT = /Dieser Schlüssel darf lesen und buchen/;
function makeKey(id: string, scopes: ApiKeyScope[]): ApiKey { function makeKey(id: string, scopes: ApiKeyScope[], overrides: Partial<ApiKey> = {}): ApiKey {
return { return {
id, id,
name: `Schlüssel ${id}`, name: `Schlüssel ${id}`,
@@ -29,6 +35,7 @@ function makeKey(id: string, scopes: ApiKeyScope[]): ApiKey {
allowedIps: [], allowedIps: [],
expiresAt: null, expiresAt: null,
revokedAt: null, revokedAt: null,
...overrides,
}; };
} }
@@ -44,47 +51,167 @@ function renderTab() {
describe("ApiKeysTab", () => { describe("ApiKeysTab", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
ownPermissions = ["api-keys:manage", "shop-sync:read", "shop-sync:write", "erp-inventory:read"];
vi.mocked(fetchApiKeys).mockReset().mockResolvedValue([]);
vi.mocked(createApiKey).mockReset();
vi.mocked(revokeApiKey).mockReset();
}); });
it("shows the separation hint only for a key with both scopes", async () => { describe("separation hint", () => {
it("shows the separation hint only for a key with both scopes", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("both", ["shop-sync:read", "shop-sync:write"]),
makeKey("read", ["shop-sync:read"]),
]);
renderTab();
const hints = await screen.findAllByRole("status");
expect(hints).toHaveLength(1);
expect(hints[0]).toHaveTextContent(HINT);
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
expect(hints[0]).toHaveTextContent(/in Wabenhain eintragen/);
});
it("also marks a key that reads the ERP mirror and books, naming both systems", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("mixed", ["erp-inventory:read", "shop-sync:write"]),
]);
renderTab();
const hint = await screen.findByRole("status");
expect(hint).toHaveTextContent(HINT);
expect(hint).toHaveTextContent(/in ERPNext bzw. Wabenhain eintragen/);
});
it("marks an older key with several read scopes", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("reads", ["shop-sync:read", "erp-inventory:read"]),
]);
renderTab();
const hint = await screen.findByRole("status");
expect(hint).toHaveTextContent(/mehrere Rechte/);
expect(hint).not.toHaveTextContent(HINT);
});
it("shows no hint when every key has one scope", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("read", ["shop-sync:read"]),
makeKey("erp", ["erp-inventory:read"]),
]);
renderTab();
await screen.findByText("Schlüssel read");
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
});
});
describe("creating", () => {
it("creates a read key by default", async () => {
vi.mocked(createApiKey).mockResolvedValue({
apiKey: makeKey("new", ["shop-sync:read"]),
token: "t",
});
renderTab();
await userEvent.click(await screen.findByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:read"]);
});
it('creates a write key when "Shop buchen" is chosen', async () => {
vi.mocked(createApiKey).mockResolvedValue({
apiKey: makeKey("new", ["shop-sync:write"]),
token: "t",
});
renderTab();
await userEvent.click(await screen.findByRole("radio", { name: "Shop buchen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
});
it("legt einen ERP-Schlüssel nur mit dem Inventar-Recht an und sagt, wohin er gehört", async () => {
vi.mocked(createApiKey).mockResolvedValueOnce({
apiKey: makeKey("erp", ["erp-inventory:read"], { name: "ERPNext" }),
// Synthetischer Wert, kein echter Schlüssel.
token: `ack_abcd1234_${"A".repeat(43)}`,
});
renderTab();
const name = screen.getByLabelText("Wofür ist der Schlüssel?");
await userEvent.clear(name);
await userEvent.type(name, "ERPNext");
await userEvent.click(screen.getByRole("radio", { name: "ERP-Inventar lesen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0]).toMatchObject({
name: "ERPNext",
scopes: ["erp-inventory:read"],
});
expect(await screen.findByText(/in ERPNext als Secret/)).toBeInTheDocument();
});
it("schlägt für den Shop wie bisher das Lesen vor, genau ein Recht je Schlüssel", () => {
renderTab();
expect(screen.getByRole("radio", { name: "Shop lesen" })).toHaveAttribute(
"aria-checked",
"true",
);
expect(screen.getByRole("radio", { name: "Shop buchen" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toHaveAttribute(
"aria-checked",
"false",
);
expect(screen.queryAllByRole("checkbox")).toHaveLength(0);
});
});
describe("only grantable scopes", () => {
it("bietet nur Rechte an, die der angemeldete Nutzer selbst hat", () => {
ownPermissions = ["api-keys:manage", "shop-sync:read", "erp-inventory:read"];
renderTab();
expect(screen.getByRole("radio", { name: "Shop lesen" })).toBeInTheDocument();
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toBeInTheDocument();
expect(screen.queryByRole("radio", { name: "Shop buchen" })).toBeNull();
});
it("wählt ein erlaubtes Recht vor, wenn der Nutzer das Shop-Lesen nicht hat", async () => {
ownPermissions = ["api-keys:manage", "erp-inventory:read"];
vi.mocked(createApiKey).mockResolvedValue({
apiKey: makeKey("erp", ["erp-inventory:read"]),
token: "t",
});
renderTab();
expect(screen.getByRole("radio", { name: "ERP-Inventar lesen" })).toHaveAttribute(
"aria-checked",
"true",
);
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["erp-inventory:read"]);
});
it("wählt nichts vor, was der Nutzer nicht vergeben darf, und erklärt es", () => {
ownPermissions = ["api-keys:manage"];
renderTab();
expect(screen.queryAllByRole("radio")).toHaveLength(0);
expect(screen.getByText(/keine Rechte, die ein API-Schlüssel tragen kann/)).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Schlüssel anlegen" })).toBeDisabled();
});
});
it("zeigt in der Liste, was ein Schlüssel darf", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([ vi.mocked(fetchApiKeys).mockResolvedValue([
makeKey("both", ["shop-sync:read", "shop-sync:write"]), makeKey("k2", ["erp-inventory:read"], { name: "ERPNext" }),
makeKey("read", ["shop-sync:read"]),
]); ]);
renderTab(); renderTab();
expect(await screen.findByText("ERPNext")).toBeInTheDocument();
const hints = await screen.findAllByRole("status"); expect(screen.getByText("Darf:")).toBeInTheDocument();
expect(hints).toHaveLength(1); expect(screen.getByText("Inventar-Spiegel lesen (ERPNext)")).toBeInTheDocument();
expect(hints[0]).toHaveTextContent(HINT);
expect(hints[0]).toHaveTextContent(/zwei getrennte Schlüssel/);
});
it("shows no hint when every key has one scope", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([makeKey("read", ["shop-sync:read"])]);
renderTab();
await screen.findByText("Schlüssel read");
expect(screen.queryByText(HINT)).not.toBeInTheDocument();
});
it("creates a read key by default", async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:read"]), token: "t" });
renderTab();
await userEvent.click(await screen.findByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:read"]);
});
it('creates a write key when "Buchen" is chosen', async () => {
vi.mocked(fetchApiKeys).mockResolvedValue([]);
vi.mocked(createApiKey).mockResolvedValue({ apiKey: makeKey("new", ["shop-sync:write"]), token: "t" });
renderTab();
await userEvent.click(await screen.findByRole("radio", { name: "Buchen" }));
await userEvent.click(screen.getByRole("button", { name: "Schlüssel anlegen" }));
expect(vi.mocked(createApiKey).mock.calls[0][0].scopes).toEqual(["shop-sync:write"]);
}); });
}); });
@@ -6,23 +6,44 @@ import { Check, Copy, KeyRound, ShieldCheck } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api"; import { createApiKey, fetchApiKeys, revokeApiKey } from "@/lib/api-keys/api";
import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types"; import { ApiKey, ApiKeyScope } from "@/lib/api-keys/types";
import { API_KEY_SCOPE_LABELS, hasCombinedShopScopes } from "@/lib/api-keys/scopes"; import {
API_KEY_SCOPE_LABELS,
API_KEY_SCOPES,
apiKeyScopeLabel,
separationAdvice,
} from "@/lib/api-keys/scopes";
import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group"; import { ChoiceGroup, choiceOptions } from "@/components/form/choice-group";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { ConfirmDialog } from "@/components/confirm-dialog"; import { ConfirmDialog } from "@/components/confirm-dialog";
import { useAuth } from "@/lib/auth/auth-context";
import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist"; import { ApiKeyAllowlist, parseAllowlist } from "./api-key-allowlist";
// Reading and booking are separate keys, so a leaked key can only do one. // Genau ein Recht je Schlüssel: Lesen und Buchen nie zusammen, und je
// angebundenem System ein eigener Schlüssel. Vorschlag wie bisher: der Shop liest.
const DEFAULT_SCOPE: ApiKeyScope = "shop-sync:read";
const SCOPE_CHOICE_LABELS: Record<ApiKeyScope, string> = { const SCOPE_CHOICE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Lesen", "shop-sync:read": "Shop lesen",
"shop-sync:write": "Buchen", "shop-sync:write": "Shop buchen",
"erp-inventory:read": "ERP-Inventar lesen",
}; };
const SCOPE_OPTIONS = choiceOptions(SCOPE_CHOICE_LABELS);
const SCOPE_LABEL_ID = "api-key-scope-label"; const SCOPE_LABEL_ID = "api-key-scope-label";
/** Wohin der frisch angelegte Schlüssel gehört - je nach Berechtigung. */
function pasteHint(scopes: readonly ApiKeyScope[]) {
if (scopes.includes("erp-inventory:read")) {
return <>Jetzt kopieren und in ERPNext als Secret für den Inventar-Abgleich hinterlegen.</>;
}
return (
<>
Jetzt kopieren und in Wabenhain als <code className="font-mono">APICORE_API_KEY</code>{" "}
eintragen.
</>
);
}
function formatDate(value: string | null): string { function formatDate(value: string | null): string {
return value return value
? new Date(value).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" }) ? new Date(value).toLocaleString("de-DE", { dateStyle: "medium", timeStyle: "short" })
@@ -38,16 +59,30 @@ function keyState(apiKey: ApiKey): { label: string; active: boolean } {
} }
/** /**
* Keys for machine access (the Wabenhain shop sync) instead of a service * Keys for machine access (the Wabenhain shop sync, ERPNext's inventory
* account with a password. The key is shown once; ApiCore keeps only a hash. * mirror) instead of a service account with a password. The key is shown
* once; ApiCore keeps only a hash.
*/ */
export function ApiKeysTab() { export function ApiKeysTab() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { hasPermission } = useAuth();
// Der Server lässt nur Rechte zu, die der Anlegende selbst hat - das UI
// bietet deshalb auch nur diese an, statt erst beim Anlegen abzulehnen.
const grantable = API_KEY_SCOPES.filter((scope) => hasPermission(scope));
const [name, setName] = useState("Wabenhain-Shop"); const [name, setName] = useState("Wabenhain-Shop");
const [expiresInDays, setExpiresInDays] = useState(""); const [expiresInDays, setExpiresInDays] = useState("");
const [allowedIps, setAllowedIps] = useState(""); const [allowedIps, setAllowedIps] = useState("");
const [scope, setScope] = useState<ApiKeyScope>("shop-sync:read"); const [chosenScope, setScope] = useState<ApiKeyScope>(DEFAULT_SCOPE);
const [created, setCreated] = useState<{ name: string; token: string } | null>(null); // Fehlt dem Nutzer das gewählte Recht, gilt das erste, das er vergeben darf.
const scope: ApiKeyScope | undefined = grantable.includes(chosenScope)
? chosenScope
: grantable[0];
const scopeOptions = choiceOptions(SCOPE_CHOICE_LABELS, grantable);
const [created, setCreated] = useState<{
name: string;
token: string;
scopes: ApiKeyScope[];
} | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const { data: keys, isLoading, isError } = useQuery({ const { data: keys, isLoading, isError } = useQuery({
@@ -58,7 +93,11 @@ export function ApiKeysTab() {
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: createApiKey, mutationFn: createApiKey,
onSuccess: (result) => { onSuccess: (result) => {
setCreated({ name: result.apiKey.name, token: result.token }); setCreated({
name: result.apiKey.name,
token: result.token,
scopes: result.apiKey.scopes,
});
setCopied(false); setCopied(false);
void queryClient.invalidateQueries({ queryKey: ["api-keys"] }); void queryClient.invalidateQueries({ queryKey: ["api-keys"] });
}, },
@@ -81,6 +120,7 @@ export function ApiKeysTab() {
function handleSubmit(event: FormEvent<HTMLFormElement>) { function handleSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
if (!scope) return;
const days = expiresInDays ? Number(expiresInDays) : undefined; const days = expiresInDays ? Number(expiresInDays) : undefined;
createMutation.mutate({ createMutation.mutate({
name: name.trim(), name: name.trim(),
@@ -104,8 +144,9 @@ export function ApiKeysTab() {
<div className="flex flex-col gap-6"> <div className="flex flex-col gap-6">
<p className="max-w-prose text-sm text-muted-foreground"> <p className="max-w-prose text-sm text-muted-foreground">
Ein API-Schlüssel erlaubt einem anderen System genau eine Sache – etwa dem Wabenhain-Shop, Ein API-Schlüssel erlaubt einem anderen System genau eine Sache – etwa dem Wabenhain-Shop,
die freigegebenen Shop-Daten zu lesen. Er ist kein Benutzerkonto, hat kein Passwort und die freigegebenen Shop-Daten zu lesen, oder dem Buchhaltungssystem, den Inventar-Spiegel zu
lässt sich jederzeit widerrufen. lesen. Er ist kein Benutzerkonto, hat kein Passwort und lässt sich
jederzeit widerrufen.
</p> </p>
{created ? ( {created ? (
@@ -118,8 +159,7 @@ export function ApiKeysTab() {
Schlüssel „{created.name}“ angelegt Schlüssel „{created.name}“ angelegt
</div> </div>
<p className="text-sm"> <p className="text-sm">
Jetzt kopieren und in Wabenhain als <code className="font-mono">APICORE_API_KEY</code>{" "} {pasteHint(created.scopes)} Er wird <strong>nur dieses eine Mal</strong> angezeigt.
eintragen. Er wird <strong>nur dieses eine Mal</strong> angezeigt.
</p> </p>
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<code className="min-w-0 flex-1 break-all rounded-lg bg-card px-3 py-2 font-mono text-xs select-all"> <code className="min-w-0 flex-1 break-all rounded-lg bg-card px-3 py-2 font-mono text-xs select-all">
@@ -148,15 +188,21 @@ export function ApiKeysTab() {
onChange={(event) => setName(event.target.value)} onChange={(event) => setName(event.target.value)}
/> />
</div> </div>
<div className="flex w-56 flex-col gap-1.5"> <div className="flex w-full flex-col gap-1.5">
<Label id={SCOPE_LABEL_ID}>Berechtigung</Label> <Label id={SCOPE_LABEL_ID}>Berechtigung</Label>
<ChoiceGroup {scope ? (
options={SCOPE_OPTIONS} <ChoiceGroup
value={scope} options={scopeOptions}
onChange={setScope} value={scope}
labelledBy={SCOPE_LABEL_ID} onChange={setScope}
className="grid-cols-2" labelledBy={SCOPE_LABEL_ID}
/> className="grid-cols-1 sm:grid-cols-3"
/>
) : (
<p className="text-sm text-muted-foreground">
Sie haben selbst keine Rechte, die ein API-Schlüssel tragen kann.
</p>
)}
</div> </div>
<div className="flex w-44 flex-col gap-1.5"> <div className="flex w-44 flex-col gap-1.5">
<Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label> <Label htmlFor="api-key-expiry">Gültig (Tage, optional)</Label>
@@ -183,15 +229,20 @@ export function ApiKeysTab() {
aria-describedby="api-key-ips-hint" aria-describedby="api-key-ips-hint"
/> />
</div> </div>
<Button type="submit" size="touch" disabled={createMutation.isPending || !name.trim()}> <Button
type="submit"
size="touch"
disabled={createMutation.isPending || !name.trim() || !scope}
>
<KeyRound aria-hidden /> <KeyRound aria-hidden />
{createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"} {createMutation.isPending ? "Wird angelegt…" : "Schlüssel anlegen"}
</Button> </Button>
</form> </form>
<p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground"> <p id="api-key-ips-hint" className="-mt-3 text-xs text-muted-foreground">
Berechtigung: {API_KEY_SCOPE_LABELS[scope]}. Lesen und Buchen bekommen je einen eigenen Schlüssel. IP-Adressen mit Komma trennen; leer {scope ? `Berechtigung: ${API_KEY_SCOPE_LABELS[scope]}. ` : null}Je angebundenem System ein
= von überall. Unbekannt? Leer lassen, einmal synchronisieren, dann hier „Adressen ändern“ – eigener Schlüssel; Lesen und Buchen bekommen je einen eigenen. IP-Adressen mit Komma trennen;
die zuletzt benutzte Adresse lässt sich übernehmen. leer = von überall. Unbekannt? Leer lassen, einmal abrufen lassen, dann hier „Adressen
ändern“ – die zuletzt benutzte Adresse lässt sich übernehmen.
</p> </p>
{isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null} {isLoading ? <p className="text-sm text-muted-foreground">Lädt…</p> : null}
@@ -208,6 +259,7 @@ export function ApiKeysTab() {
<ul className="flex flex-col gap-2"> <ul className="flex flex-col gap-2">
{keys.map((apiKey) => { {keys.map((apiKey) => {
const state = keyState(apiKey); const state = keyState(apiKey);
const advice = state.active ? separationAdvice(apiKey.scopes) : null;
return ( return (
<li <li
key={apiKey.id} key={apiKey.id}
@@ -219,18 +271,17 @@ export function ApiKeysTab() {
<Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge> <Badge variant={state.active ? "default" : "outline"}>{state.label}</Badge>
</span> </span>
<span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</span> <span className="font-mono text-xs text-muted-foreground">{apiKey.prefix}_…</span>
<span className="flex flex-wrap gap-1.5"> <span className="flex flex-wrap items-center gap-1.5">
<span className="text-xs text-muted-foreground">Darf:</span>
{apiKey.scopes.map((keyScope) => ( {apiKey.scopes.map((keyScope) => (
<Badge key={keyScope} variant="secondary"> <Badge key={keyScope} variant="secondary">
{API_KEY_SCOPE_LABELS[keyScope]} {apiKeyScopeLabel(keyScope)}
</Badge> </Badge>
))} ))}
</span> </span>
{state.active && hasCombinedShopScopes(apiKey) ? ( {advice ? (
<p role="status" className="text-xs text-muted-foreground"> <p role="status" className="text-xs text-muted-foreground">
Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte {advice}
Schlüssel: einer zum Lesen, einer zum Buchen. Neue Schlüssel anlegen, in
Wabenhain eintragen, dann diesen widerrufen.
</p> </p>
) : null} ) : null}
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
@@ -1,13 +1,66 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { hasCombinedShopScopes } from "./scopes"; import {
API_KEY_SCOPES,
apiKeyScopeLabel,
hasReadAndWriteScopes,
separationAdvice,
} from "./scopes";
describe("hasCombinedShopScopes", () => { describe("hasReadAndWriteScopes", () => {
it("is true for a key that may read and book", () => { it("is true for a key that may read and book", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true); expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read", "shop-sync:write"] })).toBe(true);
});
it("treats the ERP inventory scope as reading", () => {
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:write"] })).toBe(true);
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read", "shop-sync:read"] })).toBe(false);
}); });
it("is false for single-scope keys", () => { it("is false for single-scope keys", () => {
expect(hasCombinedShopScopes({ scopes: ["shop-sync:read"] })).toBe(false); expect(hasReadAndWriteScopes({ scopes: ["shop-sync:read"] })).toBe(false);
expect(hasCombinedShopScopes({ scopes: ["shop-sync:write"] })).toBe(false); expect(hasReadAndWriteScopes({ scopes: ["shop-sync:write"] })).toBe(false);
expect(hasReadAndWriteScopes({ scopes: ["erp-inventory:read"] })).toBe(false);
});
});
describe("separationAdvice", () => {
it("is null for a key with one scope", () => {
expect(separationAdvice(["shop-sync:read"])).toBeNull();
expect(separationAdvice(["erp-inventory:read"])).toBeNull();
});
it("names Wabenhain for a shop key that reads and books", () => {
const advice = separationAdvice(["shop-sync:read", "shop-sync:write"]);
expect(advice).toMatch(/darf lesen und buchen/);
expect(advice).toMatch(/zwei getrennte Schlüssel/);
expect(advice).toMatch(/in Wabenhain eintragen/);
});
it("names each consuming system, not only Wabenhain", () => {
const advice = separationAdvice(["erp-inventory:read", "shop-sync:write"]);
expect(advice).toMatch(/darf lesen und buchen/);
expect(advice).toMatch(/in ERPNext bzw. Wabenhain eintragen/);
});
it("advises separate keys for several read scopes too", () => {
const advice = separationAdvice(["shop-sync:read", "erp-inventory:read"]);
expect(advice).toMatch(/mehrere Rechte/);
expect(advice).toMatch(/je Recht ein eigener Schlüssel/);
expect(advice).toMatch(/in Wabenhain bzw. ERPNext eintragen/);
});
it("falls back to a neutral wording for unknown scopes", () => {
expect(separationAdvice(["future:a", "future:b"])).toMatch(/im angebundenen System eintragen/);
});
});
describe("API key scopes", () => {
it("match the backend allowlist", () => {
expect(API_KEY_SCOPES).toEqual(["shop-sync:read", "shop-sync:write", "erp-inventory:read"]);
});
it("label known scopes and pass unknown ones through", () => {
expect(apiKeyScopeLabel("erp-inventory:read")).toBe("Inventar-Spiegel lesen (ERPNext)");
expect(apiKeyScopeLabel("future:scope")).toBe("future:scope");
}); });
}); });
+45 -4
View File
@@ -1,16 +1,57 @@
import type { ApiKey } from "./types"; /** Berechtigungen, die ein API-Schlüssel tragen kann - wie API_KEY_SCOPES im Backend. */
export const API_KEY_SCOPES = ["shop-sync:read", "shop-sync:write", "erp-inventory:read"] as const;
export type ApiKeyScope = "shop-sync:read" | "shop-sync:write"; export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = { export const API_KEY_SCOPE_LABELS: Record<ApiKeyScope, string> = {
"shop-sync:read": "Shop-Daten lesen (Wabenhain)", "shop-sync:read": "Shop-Daten lesen (Wabenhain)",
"shop-sync:write": "Verkäufe und Rückbuchungen buchen (Wabenhain)", "shop-sync:write": "Verkäufe und Rückbuchungen buchen (Wabenhain)",
"erp-inventory:read": "Inventar-Spiegel lesen (ERPNext)",
}; };
/** Buchende Rechte - wie API_KEY_WRITE_SCOPES im Backend; alle anderen lesen nur. */
const WRITE_SCOPES: readonly string[] = ["shop-sync:write"];
/** Label für die Anzeige; unbekannte Rechte (neueres Backend) unverändert. */
export function apiKeyScopeLabel(scope: string): string {
return (API_KEY_SCOPE_LABELS as Record<string, string>)[scope] ?? scope;
}
/** /**
* Keys issued before reading and booking were split may still carry both; * Keys issued before reading and booking were split may still carry both;
* the server no longer creates such keys, the UI nudges towards two keys. * the server no longer creates such keys, the UI nudges towards two keys.
* The ERP inventory scope counts as reading.
*/ */
export function hasCombinedShopScopes(apiKey: Pick<ApiKey, "scopes">): boolean { export function hasReadAndWriteScopes(apiKey: { scopes: readonly string[] }): boolean {
return apiKey.scopes.includes("shop-sync:read") && apiKey.scopes.includes("shop-sync:write"); const isWrite = (scope: string) => WRITE_SCOPES.includes(scope);
return apiKey.scopes.some(isWrite) && apiKey.scopes.some((scope) => !isWrite(scope));
}
/** Welches System einen Schlüssel mit diesem Recht benutzt. */
const CONSUMING_SYSTEM: Record<ApiKeyScope, string> = {
"shop-sync:read": "Wabenhain",
"shop-sync:write": "Wabenhain",
"erp-inventory:read": "ERPNext",
};
function consumingSystems(scopes: readonly string[]): string[] {
const systems = scopes
.map((scope) => (CONSUMING_SYSTEM as Record<string, string>)[scope])
.filter((system): system is string => system !== undefined);
return [...new Set(systems)];
}
/**
* Hinweis für einen älteren Schlüssel mit mehreren Rechten (der Server legt
* nur noch Schlüssel mit genau einem Recht an), oder null. Nennt das System,
* in dem die neuen Schlüssel einzutragen sind.
*/
export function separationAdvice(scopes: readonly string[]): string | null {
if (new Set(scopes).size < 2) return null;
const systems = consumingSystems(scopes);
const where = systems.length > 0 ? `in ${systems.join(" bzw. ")}` : "im angebundenen System";
const why = hasReadAndWriteScopes({ scopes })
? "Dieser Schlüssel darf lesen und buchen. Sicherer sind zwei getrennte Schlüssel: einer zum Lesen, einer zum Buchen."
: "Dieser Schlüssel trägt mehrere Rechte. Sicherer ist je Recht ein eigener Schlüssel.";
return `${why} Neue Schlüssel anlegen, ${where} eintragen, dann diesen widerrufen.`;
} }
+12
View File
@@ -325,6 +325,18 @@ Software-seitig umsetzbare Punkte werden gebaut. Punkte, die dedizierte native B
- [x] ~~Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht~~ – nicht umsetzbar in diesem Kontext: erfordert Spracherkennungs-/LLM-Infrastruktur über das bestehende Stack hinaus - [x] ~~Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht~~ – nicht umsetzbar in diesem Kontext: erfordert Spracherkennungs-/LLM-Infrastruktur über das bestehende Stack hinaus
- [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware - [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware
## ERP-Anbindung
### T8. Inventar-Spiegel für ERPNext (Datenquelle)
- Backend: `ErpExportModule` – nur lesend, Recht `erp-inventory:read` (eigener API-Schlüssel-Scope), Fehlversuchs-Sperre für API-Schlüssel je Client-Netz (IPv6 /64), Zähler über alle api-Pods geteilt (ARCHITECTURE.md Annahme 30)
- Endpunkte: `GET /api/v1/export/inventar/produkte`, `.../chargen`, `.../bewegungen?seit=&nach=&limit=`, `.../ernten?jahr=`
- Frontend: Berechtigung beim Anlegen eines API-Schlüssels wählbar (genau eine je Schlüssel, nur eigene Rechte), Liste zeigt, was ein Schlüssel darf
- Vertrag: [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md)
- [x] Backend-Modul fertig, getestet, grün, committet
- [x] Frontend-Views fertig, getestet, grün, committet
- [ ] ERPNext-Importer (Repo ERPNext) nach dem Vertrag gebaut
## Prüfung September 2026 ## Prüfung September 2026
Prüfmatrix, Verifikationsstand und offene Entscheidungen: [`docs/pruefung-2026-09.md`](docs/pruefung-2026-09.md). Prüfmatrix, Verifikationsstand und offene Entscheidungen: [`docs/pruefung-2026-09.md`](docs/pruefung-2026-09.md).
+360
View File
@@ -0,0 +1,360 @@
# 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 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](#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.
**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.
```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.