From 68f81fabd4394b01ccb6a563fc1fbd052a67f692 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Mon, 12 Oct 2026 11:43:57 +0200 Subject: [PATCH] =?UTF-8?q?feat(erp-export):=20nur=20lesender=20Inventar-S?= =?UTF-8?q?piegel=20f=C3=BCr=20ERPNext=20(T8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Datenquelle für den nächtlichen Inventar-Abgleich in ERPNext (ERPNext- Ausbau T8, Entscheidungen E2/E3). Neues Recht erp-inventory:read, auch als API-Schlüssel-Scope; es öffnet nur diese vier GET-Endpunkte: - /api/v1/export/inventar/produkte: Artikel (ohne Beschreibung) - .../chargen: Abfüllungen und Herstellungschargen mit Losnummer, MHD, Ausgangsmenge, Rest aus dem Bewegungsbuch, Füllmenge g und Sorte - .../bewegungen?seit=&nach=&limit=: Shop-Verkauf, Retoure, Entnahmen mit Grund (HOFLADEN = Vertrauensschrank, EIGENBEDARF, BRUCH, PROBE, SONSTIGES) und Inventur; Cursor über (createdAt, id), Bewegungen erst nach 5 Minuten Wartezeit, damit der Cursor keine offene Buchung überholt - .../ernten?jahr=: Nettogewicht kg je Kalenderjahr, wie die Statistik Alle Listen stabil sortiert, seitenweise (1-500), Parameter mit class-validator geprüft, Cursor je Endpunkt streng validiert. Feld- Allowlists: keine Notizen, Benutzer, Standorte, Rohstoffe. Die Gründe gab es im Datenmodell schon - keine Migration; das Recht legt der Katalog- Sync beim Start an und gibt es der Rolle Administrator. Vertrag mit Feldern, Einheiten, Fehlercodes und Beispielen: docs/erp-inventar-export.md. Co-Authored-By: Claude Opus 5.5 (1M context) --- ARCHITECTURE.md | 5 +- .../src/api-keys/api-key-scopes.ts | 7 +- ApiCore-Backend/src/app.module.ts | 2 + .../auth/permissions/permission-catalog.ts | 9 + .../src/erp-export/dto/erp-query.dto.spec.ts | 85 +++ .../src/erp-export/dto/erp-query.dto.ts | 68 ++ .../src/erp-export/erp-cursor.spec.ts | 70 ++ ApiCore-Backend/src/erp-export/erp-cursor.ts | 85 +++ .../src/erp-export/erp-export.controller.ts | 82 +++ .../src/erp-export/erp-export.module.ts | 11 + .../src/erp-export/erp-export.service.spec.ts | 524 ++++++++++++++ .../src/erp-export/erp-export.service.ts | 423 +++++++++++ .../src/erp-export/harvest-years.spec.ts | 96 +++ .../src/erp-export/harvest-years.ts | 72 ++ ApiCore-Backend/test/erp-export.e2e-spec.ts | 664 ++++++++++++++++++ TASKS.md | 12 + docs/erp-inventar-export.md | 358 ++++++++++ 17 files changed, 2569 insertions(+), 4 deletions(-) create mode 100644 ApiCore-Backend/src/erp-export/dto/erp-query.dto.spec.ts create mode 100644 ApiCore-Backend/src/erp-export/dto/erp-query.dto.ts create mode 100644 ApiCore-Backend/src/erp-export/erp-cursor.spec.ts create mode 100644 ApiCore-Backend/src/erp-export/erp-cursor.ts create mode 100644 ApiCore-Backend/src/erp-export/erp-export.controller.ts create mode 100644 ApiCore-Backend/src/erp-export/erp-export.module.ts create mode 100644 ApiCore-Backend/src/erp-export/erp-export.service.spec.ts create mode 100644 ApiCore-Backend/src/erp-export/erp-export.service.ts create mode 100644 ApiCore-Backend/src/erp-export/harvest-years.spec.ts create mode 100644 ApiCore-Backend/src/erp-export/harvest-years.ts create mode 100644 ApiCore-Backend/test/erp-export.e2e-spec.ts create mode 100644 docs/erp-inventar-export.md diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index fd9f4e3..d79a906 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -26,6 +26,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i | `DashboardModule` | Aggregations-Endpunkte | alle o.g. | | `SearchModule` | globale Suche, Filter-Bausteine | 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. @@ -102,7 +103,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, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), 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. 12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. 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. -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. +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`: `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. 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). 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. @@ -142,7 +143,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 | | 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 | -| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben | +| 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; nach 20 abgelehnten Schlüsseln in 15 min je IP `429`; Logs nur mit Schlüssel-Präfix | | Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body | | Transport/Antworten | `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 (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` | diff --git a/ApiCore-Backend/src/api-keys/api-key-scopes.ts b/ApiCore-Backend/src/api-keys/api-key-scopes.ts index 1d80684..0442a41 100644 --- a/ApiCore-Backend/src/api-keys/api-key-scopes.ts +++ b/ApiCore-Backend/src/api-keys/api-key-scopes.ts @@ -6,12 +6,15 @@ import { PermissionKey } from '../auth/permissions/permission-catalog'; * 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 * 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 - * ApiKeysService.authenticate). + * erp-inventory:read lets ERPNext read the inventory mirror (articles, + * 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 = [ 'shop-sync:read', 'shop-sync:write', + 'erp-inventory:read', ] as const satisfies readonly PermissionKey[]; export type ApiKeyScope = (typeof API_KEY_SCOPES)[number]; diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index c38337b..cd19ec1 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -48,6 +48,7 @@ import { ProfileModule } from './profile/profile.module'; import { SiteSettingsModule } from './site-settings/site-settings.module'; import { BackupModule } from './backup/backup.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 { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; @@ -106,6 +107,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea SiteSettingsModule, BackupModule, ShopSyncModule, + ErpExportModule, ApiKeysModule, ], controllers: [], diff --git a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts index 4d86b24..9a90459 100644 --- a/ApiCore-Backend/src/auth/permissions/permission-catalog.ts +++ b/ApiCore-Backend/src/auth/permissions/permission-catalog.ts @@ -361,6 +361,15 @@ export const PERMISSION_CATALOG = [ [['manage', 'Anlegen, ansehen und widerrufen']], 3400, ), + // 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']], + 3500, + ), ] as const satisfies readonly PermissionCatalogEntry[]; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; diff --git a/ApiCore-Backend/src/erp-export/dto/erp-query.dto.spec.ts b/ApiCore-Backend/src/erp-export/dto/erp-query.dto.spec.ts new file mode 100644 index 0000000..48e8d9c --- /dev/null +++ b/ApiCore-Backend/src/erp-export/dto/erp-query.dto.spec.ts @@ -0,0 +1,85 @@ +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { + ErpHarvestsQueryDto, + ErpMovementsQueryDto, + ErpPageQueryDto, + MAX_PAGE_SIZE, +} from './erp-query.dto'; + +async function errorsOf( + cls: new () => T, + query: Record, +): Promise { + 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('nimmt einen ISO-Zeitpunkt für seit an', async () => { + expect( + await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01T00:00:00Z' }), + ).toEqual([]); + expect( + await errorsOf(ErpMovementsQueryDto, { seit: '2026-01-01' }), + ).toEqual([]); + }); + + it.each(['gestern', '2026-13-01', '01.01.2026'])( + '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']); + }); +}); diff --git a/ApiCore-Backend/src/erp-export/dto/erp-query.dto.ts b/ApiCore-Backend/src/erp-export/dto/erp-query.dto.ts new file mode 100644 index 0000000..553fb92 --- /dev/null +++ b/ApiCore-Backend/src/erp-export/dto/erp-query.dto.ts @@ -0,0 +1,68 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsInt, + IsISO8601, + IsOptional, + IsString, + Matches, + Max, + MaxLength, + Min, +} from 'class-validator'; + +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 (ISO 8601, einschließlich; UTC mit "Z" empfohlen). Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".', + example: '2026-01-01T00:00:00Z', + }) + @IsOptional() + @IsISO8601({ strict: true }) + 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; +} diff --git a/ApiCore-Backend/src/erp-export/erp-cursor.spec.ts b/ApiCore-Backend/src/erp-export/erp-cursor.spec.ts new file mode 100644 index 0000000..d6fec96 --- /dev/null +++ b/ApiCore-Backend/src/erp-export/erp-cursor.spec.ts @@ -0,0 +1,70 @@ +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' }), + ], + ] as const)('lehnt ab: %s', (_label, kind, cursor) => { + expect(() => decodeCursor(cursor, kind)).toThrow(BadRequestException); + }); +}); diff --git a/ApiCore-Backend/src/erp-export/erp-cursor.ts b/ApiCore-Backend/src/erp-export/erp-cursor.ts new file mode 100644 index 0000000..9d23374 --- /dev/null +++ b/ApiCore-Backend/src/erp-export/erp-cursor.ts @@ -0,0 +1,85 @@ +import { BadRequestException } from '@nestjs/common'; + +/** + * 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 = Extract; + +// 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 = { + 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 { + 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; +} + +function isCanonicalTimestamp(value: unknown): value is string { + if (typeof value !== 'string') return false; + const date = new Date(value); + return !Number.isNaN(date.getTime()) && date.toISOString() === value; +} + +/** Prüft den Cursor für genau diesen Endpunkt; sonst 400. */ +export function decodeCursor( + raw: string, + kind: K, +): CursorOf { + 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; +} diff --git a/ApiCore-Backend/src/erp-export/erp-export.controller.ts b/ApiCore-Backend/src/erp-export/erp-export.controller.ts new file mode 100644 index 0000000..38952b5 --- /dev/null +++ b/ApiCore-Backend/src/erp-export/erp-export.controller.ts @@ -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> { + 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> { + 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 { + return this.erpExport.movements(query); + } + + @ApiOperation({ summary: 'Geerntete kg (netto) je Kalenderjahr' }) + @ApiOkResponse({ description: 'Erntemengen je Jahr' }) + @Get('ernten') + harvests(@Query() query: ErpHarvestsQueryDto): Promise { + return this.erpExport.harvestYears(query); + } +} diff --git a/ApiCore-Backend/src/erp-export/erp-export.module.ts b/ApiCore-Backend/src/erp-export/erp-export.module.ts new file mode 100644 index 0000000..42dc73a --- /dev/null +++ b/ApiCore-Backend/src/erp-export/erp-export.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/erp-export/erp-export.service.spec.ts b/ApiCore-Backend/src/erp-export/erp-export.service.spec.ts new file mode 100644 index 0000000..89d5a4e --- /dev/null +++ b/ApiCore-Backend/src/erp-export/erp-export.service.spec.ts @@ -0,0 +1,524 @@ +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; + select?: Record; + orderBy?: unknown; + take?: number; +}; + +const NOW = new Date('2026-10-12T02:00:00.000Z'); + +function bottlingRow(id: string, overrides: Record = {}) { + 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 = {}) { + 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 = {}, +) { + 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 }; + }; + 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([]) }, + }; + 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 = new Date(NOW.getTime() - MOVEMENT_SETTLE_MS); + + 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(); + }); + }); +}); diff --git a/ApiCore-Backend/src/erp-export/erp-export.service.ts b/ApiCore-Backend/src/erp-export/erp-export.service.ts new file mode 100644 index 0000000..d675fc8 --- /dev/null +++ b/ApiCore-Backend/src/erp-export/erp-export.service.ts @@ -0,0 +1,423 @@ +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'; + +/** + * 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. + */ +export const MOVEMENT_SETTLE_MS = 5 * 60 * 1000; + +export type ErpBatchType = 'BOTTLING' | 'PRODUCTION'; + +export interface ErpPage { + 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 { + /** 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; + +function toPage( + rows: readonly T[], + limit: number, + cursorOf: (last: T) => ErpCursor, + given: string | undefined, +): Omit, '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> { + 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> { + 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 { + 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 settledUntil = new Date(Date.now() - MOVEMENT_SETTLE_MS); + + 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, + ...(query.seit !== undefined ? { gte: new Date(query.seit) } : {}), + }, + }, + ...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 { + 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), + }; + } + + private envelope( + page: Omit, 'contractVersion' | 'generatedAt'>, + ): ErpPage { + return { + contractVersion: ERP_INVENTORY_CONTRACT_VERSION, + generatedAt: new Date(), + ...page, + }; + } + + private async withRemaining( + batches: readonly BatchWithoutRemaining[], + ): Promise { + 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(), + productionIds.length > 0 + ? this.stock.remainingByProductionBatch(productionIds) + : new Map(), + ]); + return batches.map((batch) => { + const rest = + batch.batchType === 'BOTTLING' ? bottlingRest : productionRest; + return { + ...batch, + remainingQuantity: rest.get(batch.id) ?? batch.initialQuantity, + }; + }); + } +} diff --git a/ApiCore-Backend/src/erp-export/harvest-years.spec.ts b/ApiCore-Backend/src/erp-export/harvest-years.spec.ts new file mode 100644 index 0000000..951a95a --- /dev/null +++ b/ApiCore-Backend/src/erp-export/harvest-years.spec.ts @@ -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([]); + }); +}); diff --git a/ApiCore-Backend/src/erp-export/harvest-years.ts b/ApiCore-Backend/src/erp-export/harvest-years.ts new file mode 100644 index 0000000..a9a719c --- /dev/null +++ b/ApiCore-Backend/src/erp-export/harvest-years.ts @@ -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(); + 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), + })); +} diff --git a/ApiCore-Backend/test/erp-export.e2e-spec.ts b/ApiCore-Backend/test/erp-export.e2e-spec.ts new file mode 100644 index 0000000..3f43674 --- /dev/null +++ b/ApiCore-Backend/test/erp-export.e2e-spec.ts @@ -0,0 +1,664 @@ +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/api-keys.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface CreatedKeyBody { + apiKey: { id: string }; + token: string; +} + +interface PageBody { + contractVersion: number; + generatedAt: string; + items: T[]; + nextCursor: string | null; + hasMore: boolean; + settledUntil?: string; +} + +type Item = Record & { 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; + let prisma: PrismaService; + const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`; + 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 = {}; + + 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 { + 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 { + 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; + items.push(...body.items); + if (!body.hasMore) return items; + nach = body.nextCursor; + } + throw new Error('zu viele Seiten'); + } + + beforeAll(async () => { + app = await createTestApp(); + 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; + shopKey = (await createKey(['shop-sync:read', 'shop-sync:write'])).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[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 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; + 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; + 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; + 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(): Promise<{ items: Item[]; cursor: string }> { + const mine = new Set(Object.values(movementIds)); + const found: Item[] = []; + let query = `bewegungen?limit=2&seit=${at(0).toISOString()}`; + for (let page = 0; page < 40; page++) { + const body = (await get(query, erpKey).expect(200)) + .body as PageBody; + 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=2&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('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; + 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; + 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'], + ['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 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 eine Adresse nach zu vielen falschen Schlüsseln mit 429, andere 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, + }); + // Auch ein richtiger Schlüssel verrät von dort nichts mehr. + await get('produkte', erpKey, attacker).expect(429); + await get('produkte', erpKey, '203.0.113.51').expect(200); + }); + }); +}); diff --git a/TASKS.md b/TASKS.md index e9688f6..867c8f6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -292,6 +292,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] ~~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 IP +- Endpunkte: `GET /api/v1/export/inventar/produkte`, `.../chargen`, `.../bewegungen?seit=&nach=&limit=`, `.../ernten?jahr=` +- Frontend: Berechtigungen beim Anlegen eines API-Schlüssels auswählbar, 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üfmatrix, Verifikationsstand und offene Entscheidungen: [`docs/pruefung-2026-09.md`](docs/pruefung-2026-09.md). diff --git a/docs/erp-inventar-export.md b/docs/erp-inventar-export.md new file mode 100644 index 0000000..d887df0 --- /dev/null +++ b/docs/erp-inventar-export.md @@ -0,0 +1,358 @@ +# ERP-Inventar-Export (ApiCore → ERPNext) + +Stand: 2026-10-12 · Vertragsversion `1` · Betrifft: ApiCore-Backend (`src/erp-export/`), ERPNext-Abgleich (Teilprojekt T8) + +Nur lesende Schnittstelle, über die ERPNext nachts den Inventar-Spiegel holt (ERPNext-Spec `2026-10-12-erpnext-ausbau-design.md`, Abschnitt 8.3, Entscheidungen E2 und E3). ApiCore bleibt führend für Bestand, Chargen und MHD; ERPNext spiegelt nur und bucht daraus Vertrauensschrank und Eigenbedarf. + +Der ERPNext-Importer wird genau nach diesem Dokument gebaut. Änderungen am Vertrag gehen deshalb nur additiv (siehe [Versionierung](#10-versionierung)). + +## 1. Zugang + +| Punkt | Festlegung | +|---|---| +| Verfahren | API-Schlüssel als Bearer-Token: `Authorization: Bearer ack_<8 Zeichen>_<43 Zeichen>` | +| Recht | `erp-inventory:read` („Inventar-Spiegel lesen (ERPNext)“). Öffnet ausschließlich die vier Endpunkte unten. | +| Schreiben | Nicht möglich. Alle Endpunkte sind `GET`; das Recht öffnet keinen anderen Endpunkt (auch nicht `/shop-sync`). | +| Speicherung | ApiCore speichert nur den SHA-256-Hash. Gesucht wird über den Hash (eindeutiger Index), nie durch Vergleich des Geheimnisses. Der Schlüssel selbst wird genau einmal beim Anlegen angezeigt. | +| Abschalten | Jederzeit widerrufbar (wirkt sofort), optional mit Ablaufdatum. | +| Absender | Optional nur von bestimmten IP-Adressen/Netzen (Allowlist am Schlüssel). | +| Fehlversuche | Nach 20 abgelehnten Schlüsseln (fehlerhaft, unbekannt, widerrufen, abgelaufen) innerhalb von 15 Minuten antwortet ApiCore dieser IP-Adresse mit `429`, auch für richtige Schlüssel. Zusätzlich gilt das allgemeine Limit von 100 Anfragen je Minute und IP. | +| Protokoll | Abgelehnte Schlüssel werden mit Grund, Präfix (`ack_abcd1234`) und IP protokolliert, nie mit dem Schlüssel. Query-Strings landen nicht im Log. | +| Trennung | Eigener Schlüssel nur für ERPNext. Der Wabenhain-Schlüssel (`shop-sync:*`) kommt an diese Endpunkte nicht heran und umgekehrt. | + +Benutzer mit dem Recht `erp-inventory:read` (z. B. die Rolle Administrator) können die Endpunkte mit ihrem Login ebenfalls lesen, etwa zum Prüfen. + +### Schlüssel anlegen (Inhaber) + +1. ApiCore → Verwaltung → Automatisierung → API-Schlüssel. +2. Name: `ERPNext`. Unter „Was darf der Schlüssel?“ **nur** „Inventar-Spiegel lesen (ERPNext)“ ankreuzen (den vorausgewählten Shop-Haken entfernen). +3. IP-Adressen: die Adresse, von der ERPNext ApiCore erreicht (siehe [Rollout](#11-rollout)). Unbekannt? Leer lassen, einmal abrufen, dann am Schlüssel „Adressen ändern“ und die zuletzt benutzte Adresse übernehmen. +4. Schlüssel kopieren und **nur** als Kubernetes-Secret für den ERPNext-Abgleich hinterlegen (nicht in `config.json`, nicht ins Repo, nicht in Tickets). + +## 2. Allgemeines + +- Basis: `https:///api/v1/export/inventar` +- Antworten: JSON (UTF-8), `Cache-Control: no-store`. +- Zeitpunkte: ISO 8601 in UTC mit Millisekunden, z. B. `2026-10-01T08:15:30.123Z`. Kalendertage (Abfülldatum, MHD, Erntedatum) stehen als Mitternacht UTC, z. B. `2026-08-01T00:00:00.000Z`; der Tag ist der Datumsteil. +- Kennungen (`id`, `productId`, `batchId`) sind undurchsichtige Zeichenketten (cuid), stabil über die Lebensdauer des Datensatzes. +- Mengen in **Stück** (Gläser bzw. hergestellte Stück) sind ganze Zahlen; Gewichte stehen mit Einheit im Feldnamen (`fillWeightGrams`, `netWeightKg`). +- Unbekannte Query-Parameter werden mit `400` abgelehnt. + +### Seitenweises Lesen + +`produkte`, `chargen` und `bewegungen` liefern Seiten in einer gemeinsamen Hülle: + +| Feld | Typ | Bedeutung | +|---|---|---| +| `contractVersion` | number | Vertragsversion, derzeit `1` | +| `generatedAt` | string (Zeitpunkt) | Zeitpunkt der Antwort | +| `items` | array | Einträge dieser Seite, stabil sortiert | +| `nextCursor` | string \| null | Position hinter dem letzten Eintrag. Bei leerer Seite der übergebene `nach`-Wert (oder `null`, wenn keiner übergeben wurde). | +| `hasMore` | boolean | `true`, wenn hinter dieser Seite weitere Einträge liegen | + +| Parameter | Typ | Bedeutung | +|---|---|---| +| `limit` | Ganzzahl 1–500, Vorgabe 100 | Einträge je Seite | +| `nach` | Cursor | `nextCursor` der vorigen Antwort, unverändert. Undurchsichtig (base64url); nur von dem Endpunkt gültig, der ihn ausgegeben hat. | + +Ablauf: ohne `nach` beginnen, dann mit `nach=` weiterlesen, solange `hasMore` `true` ist. + +`produkte` und `chargen` sind Momentaufnahmen und werden in jedem Lauf vollständig gelesen. Ändert sich zwischen zwei Seiten ein Bestand, gleicht der nächste Lauf das aus. Bei `bewegungen` speichert der Importer den letzten `nextCursor` und liest im nächsten Lauf dort weiter. + +## 3. `GET /produkte` – Artikel + +Alle ApiCore-Artikel (auch eingestellte), sortiert nach `id` aufsteigend. + +| Feld | Typ | Bedeutung | +|---|---|---| +| `id` | string | ApiCore-Produkt-ID | +| `name` | string | Bezeichnung | +| `sku` | string \| null | Artikelnummer in ApiCore (eindeutig, optional) | +| `kind` | `BOTTLED_HONEY` \| `MANUFACTURED` | Wo der Bestand liegt: Honig je Abfüllung, andere Waren (Kerzen, Propolis …) je Herstellungscharge | +| `shopCategory` | `HONIG` \| `KERZEN` \| `PROPOLIS` \| `SONSTIGES` | Kategorie/Sorte des Artikels | +| `status` | `ACTIVE` \| `DISCONTINUED` | eingestellt = nicht mehr hergestellt; Restbestände können noch existieren | +| `packagingSize` | number \| null | Gebindegröße, Einheit in ApiCore nicht festgelegt; Konvention **Gramm** (wie der Wabenhain-Import annimmt) | +| `weight` | number \| null | Gewicht, gleiche Einheiten-Konvention | +| `salesUnit` | string \| null | Verkaufseinheit, z. B. `Glas` | +| `updatedAt` | string (Zeitpunkt) | letzte Änderung am Artikel | + +Für Honig ist die verlässliche Füllmenge `fillWeightGrams` an der Charge (siehe unten), nicht `packagingSize`. + +**Verknüpfung zu den Shop-Artikeln `WH-`** (Wabenhain kennt die ApiCore-IDs): + +- Honig: Wabenhain führt ein Shop-Produkt je Abfüllung; `Product.apicoreBottlingId` = `chargen[].id` mit `batchType = BOTTLING`. Der ApiCore-Artikel dazu steht in `chargen[].productId`. +- Andere Waren: `Product.apicoreProductId` = `produkte[].id` (Artikel mit `kind = MANUFACTURED`). + +## 4. `GET /chargen` – Chargen mit Rest + +Erst alle Abfüllungen (`BOTTLING`), dann alle Herstellungschargen (`PRODUCTION`), jeweils nach `id` aufsteigend. Rohstoff-Chargen (Wachs, Propolis) sind keine Ware und fehlen bewusst. + +| Feld | Typ | Bedeutung | +|---|---|---| +| `id` | string | ApiCore-ID der Charge | +| `batchType` | `BOTTLING` \| `PRODUCTION` | Abfüllung (Honig) oder Herstellungscharge | +| `lotNumber` | string | Losnummer (MHD-Etikett). Eindeutig **je Art**; über beide Arten hinweg nicht garantiert. Eindeutiger Schlüssel ist (`batchType`, `id`). | +| `productId` | string \| null | ApiCore-Artikel. Bei Abfüllungen optional (`null` = keinem Artikel zugeordnet). | +| `date` | string (Kalendertag) | Abfüll- bzw. Herstelldatum | +| `bestBeforeDate` | string (Kalendertag) \| null | MHD; bei Abfüllungen immer gesetzt, bei Herstellungschargen optional | +| `initialQuantity` | Ganzzahl (Stück) | abgefüllte Gläser bzw. hergestellte Stück | +| `remainingQuantity` | Ganzzahl (Stück) | Restbestand = `initialQuantity` + Summe aller Bewegungen. Kann negativ sein (Shop hat mehr verkauft als gebucht; in ApiCore rot „Prüfen“). | +| `fillWeightGrams` | number \| null | Füllmenge je Glas in Gramm; nur `BOTTLING` | +| `honeyType` | string \| null | Sorte der Honigcharge hinter der Abfüllung (z. B. `Sommertracht`); nur `BOTTLING`, `null` wenn nicht erfasst | + +Hinweise für den Abgleich: + +- `remainingQuantity` ist der Sollwert für den Bestandsabgleich (Stock Reconciliation) im Lager „Imkerei (ApiCore)“. Er enthält alle Bewegungen bis zum Abruf, auch die der letzten Minuten, die `/bewegungen` noch zurückhält. +- Wird `initialQuantity` in ApiCore korrigiert, ändert sich der Rest sofort mit. +- Eine irrtümlich angelegte Herstellungscharge ohne Bewegungen kann in ApiCore gelöscht werden; sie fehlt dann in der Liste und ist im Spiegel auf 0 zu setzen. Abfüllungen und Chargen mit Bewegungen werden nie gelöscht. + +## 5. `GET /bewegungen` – Bestandsbewegungen + +Jede Bestandsänderung einer Abfüllung oder Herstellungscharge, nie gelöscht (Fehler werden in ApiCore gegengebucht). Sortiert nach (`createdAt`, `id`) aufsteigend. + +| Parameter | Typ | Bedeutung | +|---|---|---| +| `seit` | ISO-Zeitpunkt oder -Datum | Nur Bewegungen ab diesem Zeitpunkt (einschließlich). Für den ersten Lauf, z. B. `seit=2026-01-01T00:00:00Z`. Nicht zusammen mit `nach`. | +| `nach` | Cursor | Weiterlesen hinter der zuletzt gelieferten Bewegung | +| `limit` | 1–500 | wie oben | + +Ohne `seit` und `nach` beginnt die Liste bei der ältesten Bewegung. + +**Wartezeit:** Es erscheinen nur Bewegungen, die älter als **5 Minuten** sind. Eine Buchung bekommt ihren Zeitstempel beim Einfügen, sichtbar wird sie erst beim Abschluss ihrer Transaktion; ohne Wartezeit könnte der Cursor über eine noch offene, frühere Buchung hinweglaufen. Die Antwort nennt die Grenze zusätzlich: + +| Feld | Typ | Bedeutung | +|---|---|---| +| `settledUntil` | string (Zeitpunkt) | Bis hierhin ist die Liste vollständig (Abrufzeit minus 5 Minuten) | + +Felder je Bewegung: + +| Feld | Typ | Bedeutung | +|---|---|---| +| `id` | string | ApiCore-ID der Bewegung; zum Entdoppeln im Importer | +| `createdAt` | string (Zeitpunkt) | Buchungszeitpunkt | +| `batchType` | `BOTTLING` \| `PRODUCTION` | Art der Charge | +| `batchId` | string | `chargen[].id` | +| `lotNumber` | string | Losnummer der Charge (zur Bequemlichkeit) | +| `productId` | string \| null | Artikel der Charge | +| `type` | siehe Tabelle | Art der Bewegung | +| `reason` | siehe Tabelle \| null | Grund, nur bei `WITHDRAWAL` | +| `quantity` | Ganzzahl (Stück) | mit Vorzeichen: **minus = aus dem Bestand**, plus = zurück in den Bestand | + +| `type` | `reason` | Vorzeichen | Bedeutung | Behandlung in ERPNext (Spec 8.3) | +|---|---|---|---|---| +| `WITHDRAWAL` | `HOFLADEN` | − | Ware in den **Vertrauensschrank** gestellt | Umlagerung „Imkerei (ApiCore)“ → „Vertrauensschrank“ | +| `WITHDRAWAL` | `EIGENBEDARF` | − | Eigenverbrauch | unentgeltliche Wertabgabe zu Herstellkosten | +| `WITHDRAWAL` | `BRUCH` | − | Bruch | nur im Spiegel | +| `WITHDRAWAL` | `PROBE` | − | Probe/Muster | nur im Spiegel | +| `WITHDRAWAL` | `SONSTIGES` | − | sonstige Entnahme | nur im Spiegel | +| `SHOP_SALE` | `null` | − | **Verkauf** im Wabenhain-Shop (bezahlt) | nur im Spiegel; die Rechnung kommt aus dem Shop-Import | +| `SHOP_RETURN` | `null` | + | **Retoure**/Storno einer bezahlten Shop-Bestellung | nur im Spiegel | +| `CORRECTION` | `null` | ± | Inventurkorrektur (gezählt minus gebucht), auch zum Gegenbuchen einer Fehlbuchung | nur im Spiegel | + +Rohstoff-Bewegungen (Wachs, Propolis, Art `CONSUMPTION`) gehören nicht zur Ware und erscheinen nie. Kommt künftig ein weiterer `type` oder `reason` hinzu, behandelt der Importer ihn als „nur im Spiegel“ und meldet ihn im Cockpit. + +Ablauf im Importer: erster Lauf mit `seit=`, danach immer mit dem gespeicherten `nextCursor`. Derselbe Cursor liefert bei erneutem Abruf dieselben Bewegungen (plus inzwischen hinzugekommene); der Importer entdoppelt trotzdem über `id`. + +## 6. `GET /ernten` – Erntemengen je Jahr + +Grundlage für die Honigkosten je kg (Imkerei-Ausgaben des Jahres / geerntete kg). Nicht seitenweise (eine Zeile je Jahr). + +| Parameter | Typ | Bedeutung | +|---|---|---| +| `jahr` | Ganzzahl 1900–2100, optional | nur dieses Kalenderjahr; ohne Angabe alle Jahre mit Ernten | + +| Feld | Typ | Bedeutung | +|---|---|---| +| `contractVersion` | number | `1` | +| `generatedAt` | string (Zeitpunkt) | Zeitpunkt der Antwort | +| `years[].year` | Ganzzahl | Kalenderjahr (aus dem Erntedatum, UTC) | +| `years[].netWeightKg` | number | Summe der Nettogewichte in **kg**, auf Gramm (3 Nachkommastellen) gerundet | +| `years[].harvestCount` | Ganzzahl | Anzahl Ernten im Jahr | +| `years[].harvestsWithoutNetWeight` | Ganzzahl | davon ohne erfasstes Nettogewicht (zählen nicht zur Menge) | + +Definition wie die ApiCore-Statistik „Honigertrag je Jahr“: Summe `netWeight` der Ernten mit Erntedatum im Jahr. Mit `jahr` kommt immer genau eine Zeile, bei einem Jahr ohne Ernten mit Nullen. Ist `harvestsWithoutNetWeight > 0`, sind die Kosten je kg zu hoch – der Importer sollte das im Cockpit anzeigen. Standorte, Völker und Notizen sind nicht enthalten. + +## 7. Fehler + +Fehlerantworten sind JSON mit `statusCode` und `message` (bei Validierungsfehlern `message` als Liste). + +| Status | Wann | Verhalten des Importers | +|---|---|---| +| `400` | `limit` außerhalb 1–500 oder keine Ganzzahl; `nach` kein gültiger Cursor oder von einem anderen Endpunkt; `seit` kein ISO-Zeitpunkt; `seit` und `nach` zusammen; `jahr` außerhalb 1900–2100; unbekannter Parameter | Programmfehler – Schritt abbrechen, im Cockpit melden | +| `401` | Schlüssel fehlt, ist fehlerhaft, unbekannt, widerrufen oder abgelaufen | Schritt abbrechen; Hinweis „ApiCore-Schlüssel prüfen“ | +| `403` | Schlüssel ohne `erp-inventory:read`, oder IP-Adresse nicht in der Allowlist des Schlüssels | Schritt abbrechen; Hinweis „Recht bzw. Adressen am Schlüssel prüfen“ | +| `429` | zu viele Fehlversuche oder Anfragen von dieser Adresse | Schritt abbrechen, im nächsten Lauf erneut | +| `5xx` / keine Antwort | Störung in ApiCore | Schritt abbrechen, im nächsten Lauf erneut | + +Ein fehlgeschlagener Abruf ist nie „leer“: der Importer setzt dann keine Bestände auf 0 und schiebt keinen Cursor weiter. Die anderen Schritte des Abgleich-Jobs laufen weiter. + +## 8. Beispiele + +Alle Werte synthetisch. + +```http +GET /api/v1/export/inventar/produkte?limit=2 +Authorization: Bearer +``` + +```json +{ + "contractVersion": 1, + "generatedAt": "2026-10-13T02:00:04.512Z", + "items": [ + { + "id": "cmg0honig500sommer01", + "name": "Sommertracht 500 g", + "sku": "HON-SO-500", + "kind": "BOTTLED_HONEY", + "shopCategory": "HONIG", + "status": "ACTIVE", + "packagingSize": 500, + "weight": 500, + "salesUnit": "Glas", + "updatedAt": "2026-09-02T07:41:10.000Z" + }, + { + "id": "cmg0kerze0bienen0001", + "name": "Bienenwachskerze klein", + "sku": null, + "kind": "MANUFACTURED", + "shopCategory": "KERZEN", + "status": "ACTIVE", + "packagingSize": null, + "weight": 120, + "salesUnit": "Stück", + "updatedAt": "2026-09-20T15:03:55.000Z" + } + ], + "nextCursor": "eyJrIjoicHJvZHVrdGUiLCJpZCI6ImNtZzBrZXJ6ZTBiaWVuZW4wMDAxIn0", + "hasMore": true +} +``` + +```http +GET /api/v1/export/inventar/chargen?limit=500 +``` + +```json +{ + "contractVersion": 1, + "generatedAt": "2026-10-13T02:00:05.130Z", + "items": [ + { + "id": "cmg0abf0sommer2026a1", + "batchType": "BOTTLING", + "lotNumber": "L-2026-014", + "productId": "cmg0honig500sommer01", + "date": "2026-08-01T00:00:00.000Z", + "bestBeforeDate": "2028-08-01T00:00:00.000Z", + "initialQuantity": 60, + "remainingQuantity": 49, + "fillWeightGrams": 500, + "honeyType": "Sommertracht" + }, + { + "id": "cmg0her0kerze2026b01", + "batchType": "PRODUCTION", + "lotNumber": "K-2026-003", + "productId": "cmg0kerze0bienen0001", + "date": "2026-09-01T00:00:00.000Z", + "bestBeforeDate": null, + "initialQuantity": 20, + "remainingQuantity": 19, + "fillWeightGrams": null, + "honeyType": null + } + ], + "nextCursor": "eyJrIjoiY2hhcmdlbiIsInQiOiJQIiwiaWQiOiJjbWcwaGVyMGtlcnplMjAyNmIwMSJ9", + "hasMore": false +} +``` + +```http +GET /api/v1/export/inventar/bewegungen?seit=2026-01-01T00:00:00Z&limit=3 +``` + +```json +{ + "contractVersion": 1, + "generatedAt": "2026-10-13T02:00:05.877Z", + "settledUntil": "2026-10-13T01:55:05.877Z", + "items": [ + { + "id": "cmg0bew000000000001", + "createdAt": "2026-09-05T16:20:11.402Z", + "batchType": "BOTTLING", + "batchId": "cmg0abf0sommer2026a1", + "lotNumber": "L-2026-014", + "productId": "cmg0honig500sommer01", + "type": "WITHDRAWAL", + "reason": "HOFLADEN", + "quantity": -6 + }, + { + "id": "cmg0bew000000000002", + "createdAt": "2026-09-06T09:02:47.118Z", + "batchType": "BOTTLING", + "batchId": "cmg0abf0sommer2026a1", + "lotNumber": "L-2026-014", + "productId": "cmg0honig500sommer01", + "type": "SHOP_SALE", + "reason": null, + "quantity": -3 + }, + { + "id": "cmg0bew000000000003", + "createdAt": "2026-09-12T18:44:03.950Z", + "batchType": "BOTTLING", + "batchId": "cmg0abf0sommer2026a1", + "lotNumber": "L-2026-014", + "productId": "cmg0honig500sommer01", + "type": "WITHDRAWAL", + "reason": "EIGENBEDARF", + "quantity": -2 + } + ], + "nextCursor": "eyJrIjoiYmV3ZWd1bmdlbiIsInRzIjoiMjAyNi0wOS0xMlQxODo0NDowMy45NTBaIiwiaWQiOiJjbWcwYmV3MDAwMDAwMDAwMDAzIn0", + "hasMore": true +} +``` + +```http +GET /api/v1/export/inventar/ernten?jahr=2026 +``` + +```json +{ + "contractVersion": 1, + "generatedAt": "2026-10-13T02:00:06.240Z", + "years": [ + { + "year": 2026, + "netWeightKg": 184.35, + "harvestCount": 7, + "harvestsWithoutNetWeight": 1 + } + ] +} +``` + +```json +{ "statusCode": 400, "message": "Ungültiger Cursor - bitte nextCursor der vorigen Seite unverändert als \"nach\" übergeben", "error": "Bad Request" } +``` + +## 9. Was nicht enthalten ist + +Bewusst nicht im Export (Feld-Allowlist im Code, `src/erp-export/erp-export.service.ts`): Notizen an Chargen und Bewegungen, Benutzer (wer gebucht hat), API-Schlüssel und Shop-Anfrage-Kennungen, Artikelbeschreibungen (in ApiCore interne Notizen), Standorte, Völker, Behandlungen, Rohstoffe. Neue Felder an den ApiCore-Modellen erscheinen erst, wenn sie hier ausdrücklich ergänzt werden. + +## 10. Versionierung + +`contractVersion` steigt nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in der Bedeutung geändert). Neue Felder, neue Enum-Werte und neue Endpunkte kommen ohne Versionswechsel; der Importer ignoriert unbekannte Felder und bricht bei einer anderen `contractVersion` ab, ohne etwas zu ändern. + +## 11. Rollout + +1. ApiCore ausrollen. Keine Datenbank-Migration nötig: das Recht `erp-inventory:read` legt ApiCore beim Start an und gibt es der Systemrolle Administrator. +2. Schlüssel anlegen (Abschnitt 1). +3. Schlüssel als Kubernetes-Secret für den ERPNext-Abgleich ablegen, dazu die Basis-URL (`…/api/v1`) als Konfiguration. +4. Erreichbarkeit: wie der Wabenhain-Shop über die öffentliche ApiCore-Adresse. Dann ist die Absender-IP die Ausgangsadresse des Clusters, und die Allowlist des Schlüssels kann genau diese Adresse enthalten. Ein Aufruf über den clusterinternen Service ginge mit wechselnder Pod-Adresse ein und bräuchte eine NetworkPolicy-Freigabe; die Allowlist taugt dann nur noch als Netzbereich. +5. Probeabruf aller vier Endpunkte, dann den nächtlichen Schritt aktivieren.