feat(erp-export): nur lesender Inventar-Spiegel für ERPNext (T8)

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) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 11:43:57 +02:00
1 parent 1a1e70c94b
commit 68f81fabd4
17 files changed
+2569 -4

No files matched your search

+3 -2
View File
@@ -26,6 +26,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. | | `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. | | `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. | | `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig. Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig.
@@ -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. 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. 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. 13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10). 15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt. 16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
@@ -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 | | Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) | | Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten | | Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`), 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 | | 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 | | 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` | | Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
@@ -6,12 +6,15 @@ import { PermissionKey } from '../auth/permissions/permission-catalog';
* only ever gets what a machine integration needs. shop-sync:write lets the * only ever gets what a machine integration needs. shop-sync:write lets the
* shop book its own sales and returns - nothing else - and every booking is * shop book its own sales and returns - nothing else - and every booking is
* idempotent and kept in the ledger, so a leaked key cannot erase history. * idempotent and kept in the ledger, so a leaked key cannot erase history.
* Removing an entry here also takes it away from existing keys (see * erp-inventory:read lets ERPNext read the inventory mirror (articles,
* ApiKeysService.authenticate). * batches with remaining stock, stock movements, harvest totals) - read
* only, no people, no notes. Removing an entry here also takes it away from
* existing keys (see ApiKeysService.authenticate).
*/ */
export const API_KEY_SCOPES = [ export const API_KEY_SCOPES = [
'shop-sync:read', 'shop-sync:read',
'shop-sync:write', 'shop-sync:write',
'erp-inventory:read',
] as const satisfies readonly PermissionKey[]; ] as const satisfies readonly PermissionKey[];
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number]; export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
+2
View File
@@ -48,6 +48,7 @@ import { ProfileModule } from './profile/profile.module';
import { SiteSettingsModule } from './site-settings/site-settings.module'; import { SiteSettingsModule } from './site-settings/site-settings.module';
import { BackupModule } from './backup/backup.module'; import { BackupModule } from './backup/backup.module';
import { ShopSyncModule } from './shop-sync/shop-sync.module'; import { ShopSyncModule } from './shop-sync/shop-sync.module';
import { ErpExportModule } from './erp-export/erp-export.module';
import { ApiKeysModule } from './api-keys/api-keys.module'; import { ApiKeysModule } from './api-keys/api-keys.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard';
@@ -106,6 +107,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
SiteSettingsModule, SiteSettingsModule,
BackupModule, BackupModule,
ShopSyncModule, ShopSyncModule,
ErpExportModule,
ApiKeysModule, ApiKeysModule,
], ],
controllers: [], controllers: [],
@@ -361,6 +361,15 @@ export const PERMISSION_CATALOG = [
[['manage', 'Anlegen, ansehen und widerrufen']], [['manage', 'Anlegen, ansehen und widerrufen']],
3400, 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[]; ] as const satisfies readonly PermissionCatalogEntry[];
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key']; export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
@@ -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<T extends object>(
cls: new () => T,
query: Record<string, string>,
): Promise<string[]> {
const errors = await validate(plainToInstance(cls, query), {
whitelist: true,
forbidNonWhitelisted: true,
});
return errors.map((error) => error.property);
}
describe('ERP-Abfrageparameter', () => {
it('nimmt eine leere Abfrage an', async () => {
expect(await errorsOf(ErpPageQueryDto, {})).toEqual([]);
expect(await errorsOf(ErpMovementsQueryDto, {})).toEqual([]);
expect(await errorsOf(ErpHarvestsQueryDto, {})).toEqual([]);
});
it('wandelt limit in eine Zahl um', () => {
const dto = plainToInstance(ErpPageQueryDto, { limit: '25' });
expect(dto.limit).toBe(25);
});
it.each(['0', '-1', String(MAX_PAGE_SIZE + 1), '2.5', 'viele'])(
'lehnt limit=%s ab',
async (limit) => {
expect(await errorsOf(ErpPageQueryDto, { limit })).toEqual(['limit']);
},
);
it('nimmt limit an der Obergrenze an', async () => {
expect(
await errorsOf(ErpPageQueryDto, { limit: String(MAX_PAGE_SIZE) }),
).toEqual([]);
});
it.each(["abc'def", 'a b', 'a'.repeat(513), 'abc=='])(
'lehnt den Cursor %s ab',
async (nach) => {
expect(await errorsOf(ErpPageQueryDto, { nach })).toEqual(['nach']);
},
);
it('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']);
});
});
@@ -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;
}
@@ -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);
});
});
@@ -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<K extends ErpCursorKind> = Extract<ErpCursor, { k: K }>;
// cuid-artige Kennungen; großzügig, aber ohne Zeichen, die in einer Abfrage
// etwas anderes bedeuten könnten.
const ID_PATTERN = /^[A-Za-z0-9_-]{1,64}$/;
const FIELDS: Record<ErpCursorKind, readonly string[]> = {
produkte: ['id', 'k'],
chargen: ['id', 'k', 't'],
bewegungen: ['id', 'k', 'ts'],
};
function invalid(): BadRequestException {
return new BadRequestException(
'Ungültiger Cursor - bitte nextCursor der vorigen Seite unverändert als "nach" übergeben',
);
}
export function encodeCursor(cursor: ErpCursor): string {
return Buffer.from(JSON.stringify(cursor)).toString('base64url');
}
function parse(raw: string): Record<string, unknown> {
let value: unknown;
try {
value = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
} catch {
throw invalid();
}
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw invalid();
}
return value as Record<string, unknown>;
}
function isCanonicalTimestamp(value: unknown): value is string {
if (typeof value !== 'string') return false;
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<K extends ErpCursorKind>(
raw: string,
kind: K,
): CursorOf<K> {
const value = parse(raw);
const keys = Object.keys(value).sort();
const expected = FIELDS[kind];
if (
value.k !== kind ||
keys.length !== expected.length ||
keys.some((key, index) => key !== expected[index]) ||
typeof value.id !== 'string' ||
!ID_PATTERN.test(value.id)
) {
throw invalid();
}
if (kind === 'chargen' && value.t !== 'B' && value.t !== 'P') {
throw invalid();
}
if (kind === 'bewegungen' && !isCanonicalTimestamp(value.ts)) {
throw invalid();
}
return value as CursorOf<K>;
}
@@ -0,0 +1,82 @@
import { Controller, Get, Query } from '@nestjs/common';
import {
ApiBadRequestResponse,
ApiBearerAuth,
ApiForbiddenResponse,
ApiOkResponse,
ApiOperation,
ApiTags,
ApiTooManyRequestsResponse,
ApiUnauthorizedResponse,
} from '@nestjs/swagger';
import { RequirePermission } from '../auth/decorators/require-permission.decorator';
import {
ErpHarvestsQueryDto,
ErpMovementsQueryDto,
ErpPageQueryDto,
} from './dto/erp-query.dto';
import {
ErpBatch,
ErpExportService,
ErpHarvestYears,
ErpMovementPage,
ErpPage,
ErpProduct,
} from './erp-export.service';
/**
* Inventar-Spiegel für ERPNext, nur lesend. Vertrag mit Feldern, Einheiten,
* Paginierung und Fehlercodes: docs/erp-inventar-export.md.
*/
@ApiTags('erp-export')
@ApiBearerAuth()
@ApiUnauthorizedResponse({
description: 'Kein, unbekannter, widerrufener oder abgelaufener Schlüssel',
})
@ApiForbiddenResponse({
description:
'Schlüssel ohne erp-inventory:read oder von einer nicht freigegebenen IP-Adresse',
})
@ApiTooManyRequestsResponse({
description: 'Zu viele Anfragen oder Fehlversuche von dieser Adresse',
})
@ApiBadRequestResponse({ description: 'Ungültiger Parameter oder Cursor' })
@RequirePermission('erp-inventory:read')
@Controller('export/inventar')
export class ErpExportController {
constructor(private readonly erpExport: ErpExportService) {}
@ApiOperation({ summary: 'Artikel (ApiCore-Produkte), nach id sortiert' })
@ApiOkResponse({ description: 'Eine Seite Artikel' })
@Get('produkte')
products(@Query() query: ErpPageQueryDto): Promise<ErpPage<ErpProduct>> {
return this.erpExport.products(query);
}
@ApiOperation({
summary:
'Chargen (Abfüllungen, dann Herstellungschargen) mit Ausgangsmenge und Rest',
})
@ApiOkResponse({ description: 'Eine Seite Chargen' })
@Get('chargen')
batches(@Query() query: ErpPageQueryDto): Promise<ErpPage<ErpBatch>> {
return this.erpExport.batches(query);
}
@ApiOperation({
summary:
'Bestandsbewegungen (Shop-Verkauf, Retoure, Entnahme mit Grund, Inventur), älteste zuerst',
})
@ApiOkResponse({ description: 'Eine Seite Bewegungen' })
@Get('bewegungen')
movements(@Query() query: ErpMovementsQueryDto): Promise<ErpMovementPage> {
return this.erpExport.movements(query);
}
@ApiOperation({ summary: 'Geerntete kg (netto) je Kalenderjahr' })
@ApiOkResponse({ description: 'Erntemengen je Jahr' })
@Get('ernten')
harvests(@Query() query: ErpHarvestsQueryDto): Promise<ErpHarvestYears> {
return this.erpExport.harvestYears(query);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { StockModule } from '../stock/stock.module';
import { ErpExportController } from './erp-export.controller';
import { ErpExportService } from './erp-export.service';
@Module({
imports: [StockModule],
controllers: [ErpExportController],
providers: [ErpExportService],
})
export class ErpExportModule {}
@@ -0,0 +1,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<string, unknown>;
select?: Record<string, unknown>;
orderBy?: unknown;
take?: number;
};
const NOW = new Date('2026-10-12T02:00:00.000Z');
function bottlingRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
lotNumber: `L-${id}`,
productId: 'prod-honig',
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
jarCount: 60,
jarSize: 500,
honeyBatch: { honeyType: 'Sommertracht' },
...overrides,
};
}
function productionRow(id: string, overrides: Record<string, unknown> = {}) {
return {
id,
lotNumber: `K-${id}`,
productId: 'prod-kerze',
date: new Date('2026-09-01T00:00:00.000Z'),
bestBeforeDate: null,
quantity: 20,
...overrides,
};
}
function movementRow(
id: string,
createdAt: string,
overrides: Record<string, unknown> = {},
) {
return {
id,
createdAt: new Date(createdAt),
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -2,
bottlingBatchId: 'b1',
productionBatchId: null,
bottlingBatch: { lotNumber: 'L-b1', productId: 'prod-honig' },
productionBatch: null,
...overrides,
};
}
describe('ErpExportService', () => {
let prisma: {
product: { findMany: jest.Mock };
bottlingBatch: { findMany: jest.Mock };
productionBatch: { findMany: jest.Mock };
batchMovement: { findMany: jest.Mock };
harvest: { findMany: jest.Mock };
};
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();
});
});
});
@@ -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<T> {
contractVersion: number;
generatedAt: Date;
items: T[];
/** Position hinter dem letzten Eintrag; bei leerer Seite der übergebene Cursor. */
nextCursor: string | null;
hasMore: boolean;
}
export interface ErpProduct {
id: string;
name: string;
sku: string | null;
kind: ProductKind;
shopCategory: ShopCategory;
status: ProductStatus;
packagingSize: number | null;
weight: number | null;
salesUnit: string | null;
updatedAt: Date;
}
export interface ErpBatch {
id: string;
batchType: ErpBatchType;
lotNumber: string;
productId: string | null;
/** Abfüll- bzw. Herstelldatum. */
date: Date;
bestBeforeDate: Date | null;
/** Abgefüllte Gläser bzw. hergestellte Stück. */
initialQuantity: number;
/** Ausgangsmenge plus Summe aller Bewegungen; kann negativ sein (zu prüfen). */
remainingQuantity: number;
/** Füllmenge je Glas in Gramm; nur bei Abfüllungen. */
fillWeightGrams: number | null;
/** Sorte der Honigcharge hinter der Abfüllung; nur bei Abfüllungen. */
honeyType: string | null;
}
export interface ErpMovement {
id: string;
createdAt: Date;
batchType: ErpBatchType;
batchId: string;
lotNumber: string;
productId: string | null;
type: BatchMovementType;
reason: WithdrawalReason | null;
/** Stück mit Vorzeichen: minus = aus dem Bestand, plus = zurück. */
quantity: number;
}
export interface ErpMovementPage extends ErpPage<ErpMovement> {
/** Bis hierhin ist die Liste vollständig (jetzt minus Wartezeit). */
settledUntil: Date;
}
export interface ErpHarvestYears {
contractVersion: number;
generatedAt: Date;
years: HarvestYear[];
}
// Feld-Allowlists: nur was hier steht, verlässt ApiCore. Notizen, Benutzer,
// Beschreibungen (in ApiCore interne Notizen) und alles, was später an die
// Modelle kommt, bleiben drin.
const PRODUCT_SELECT = {
id: true,
name: true,
sku: true,
kind: true,
shopCategory: true,
status: true,
packagingSize: true,
weight: true,
salesUnit: true,
updatedAt: true,
} satisfies Prisma.ProductSelect;
const BOTTLING_SELECT = {
id: true,
lotNumber: true,
productId: true,
date: true,
bestBeforeDate: true,
jarCount: true,
jarSize: true,
honeyBatch: { select: { honeyType: true } },
} satisfies Prisma.BottlingBatchSelect;
const PRODUCTION_SELECT = {
id: true,
lotNumber: true,
productId: true,
date: true,
bestBeforeDate: true,
quantity: true,
} satisfies Prisma.ProductionBatchSelect;
const BATCH_REF_SELECT = { select: { lotNumber: true, productId: true } };
const MOVEMENT_SELECT = {
id: true,
createdAt: true,
type: true,
reason: true,
quantity: true,
bottlingBatchId: true,
productionBatchId: true,
bottlingBatch: BATCH_REF_SELECT,
productionBatch: BATCH_REF_SELECT,
} satisfies Prisma.BatchMovementSelect;
type BottlingRow = Prisma.BottlingBatchGetPayload<{
select: typeof BOTTLING_SELECT;
}>;
type ProductionRow = Prisma.ProductionBatchGetPayload<{
select: typeof PRODUCTION_SELECT;
}>;
type MovementRow = Prisma.BatchMovementGetPayload<{
select: typeof MOVEMENT_SELECT;
}>;
/** Chargen ohne Rest - der kommt nach dem Zuschnitt der Seite dazu. */
type BatchWithoutRemaining = Omit<ErpBatch, 'remainingQuantity'>;
function toPage<T>(
rows: readonly T[],
limit: number,
cursorOf: (last: T) => ErpCursor,
given: string | undefined,
): Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'> {
const items = rows.slice(0, limit);
const last = items[items.length - 1];
return {
items,
hasMore: rows.length > limit,
nextCursor:
last !== undefined ? encodeCursor(cursorOf(last)) : (given ?? null),
};
}
function fromBottling(row: BottlingRow): BatchWithoutRemaining {
return {
id: row.id,
batchType: 'BOTTLING',
lotNumber: row.lotNumber,
productId: row.productId,
date: row.date,
bestBeforeDate: row.bestBeforeDate,
initialQuantity: row.jarCount,
fillWeightGrams: row.jarSize,
honeyType: row.honeyBatch.honeyType,
};
}
function fromProduction(row: ProductionRow): BatchWithoutRemaining {
return {
id: row.id,
batchType: 'PRODUCTION',
lotNumber: row.lotNumber,
productId: row.productId,
date: row.date,
bestBeforeDate: row.bestBeforeDate,
initialQuantity: row.quantity,
fillWeightGrams: null,
honeyType: null,
};
}
function fromMovement(row: MovementRow): ErpMovement {
const batch = row.bottlingBatch ?? row.productionBatch;
const batchId = row.bottlingBatchId ?? row.productionBatchId;
if (!batch || !batchId) {
// Durch die Abfrage ausgeschlossen (nur Bewegungen mit Charge).
throw new Error(
`Bewegung ${row.id} ohne Abfüllung oder Herstellungscharge`,
);
}
return {
id: row.id,
createdAt: row.createdAt,
batchType: row.bottlingBatchId ? 'BOTTLING' : 'PRODUCTION',
batchId,
lotNumber: batch.lotNumber,
productId: batch.productId,
type: row.type,
reason: row.reason,
quantity: row.quantity,
};
}
function idAfter(id: string | undefined): { id: { gt: string } } | undefined {
return id !== undefined ? { id: { gt: id } } : undefined;
}
/**
* Nur lesender Inventar-Export für ERPNext (Teilprojekt T8 des
* ERPNext-Ausbaus): Artikel, Chargen mit Rest, Bestandsbewegungen und
* Erntemengen. ApiCore bleibt führend; ERPNext spiegelt nur.
*
* Zugang über einen API-Schlüssel mit dem Recht erp-inventory:read, das nur
* diese Endpunkte öffnet. Alle Listen sind stabil sortiert und seitenweise.
*/
@Injectable()
export class ErpExportService {
constructor(
private readonly prisma: PrismaService,
private readonly stock: StockService,
) {}
async products(query: ErpPageQueryDto): Promise<ErpPage<ErpProduct>> {
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'produkte') : null;
const rows = await this.prisma.product.findMany({
where: idAfter(after?.id),
select: PRODUCT_SELECT,
orderBy: { id: 'asc' },
take: limit + 1,
});
return this.envelope(
toPage(
rows,
limit,
(last) => ({ k: 'produkte', id: last.id }),
query.nach,
),
);
}
/** Erst alle Abfüllungen, dann alle Herstellungschargen, je nach id. */
async batches(query: ErpPageQueryDto): Promise<ErpPage<ErpBatch>> {
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'chargen') : null;
const bottlings =
after?.t === 'P'
? []
: await this.prisma.bottlingBatch.findMany({
where: idAfter(after?.id),
select: BOTTLING_SELECT,
orderBy: { id: 'asc' },
take: limit + 1,
});
// Erst wenn die Abfüllungen auf dieser Seite enden, kommen
// Herstellungschargen dazu - eine mehr als Platz ist, um weitere zu erkennen.
const productions =
bottlings.length > limit
? []
: await this.prisma.productionBatch.findMany({
where: idAfter(after?.t === 'P' ? after.id : undefined),
select: PRODUCTION_SELECT,
orderBy: { id: 'asc' },
take: limit - bottlings.length + 1,
});
const page = toPage(
[...bottlings.map(fromBottling), ...productions.map(fromProduction)],
limit,
(last) => ({
k: 'chargen',
t: last.batchType === 'BOTTLING' ? 'B' : 'P',
id: last.id,
}),
query.nach,
);
return this.envelope({
...page,
items: await this.withRemaining(page.items),
});
}
async movements(query: ErpMovementsQueryDto): Promise<ErpMovementPage> {
if (query.seit !== undefined && query.nach !== undefined) {
throw new BadRequestException(
'Entweder "seit" (erster Abruf) oder "nach" (weiterlesen) angeben, nicht beides',
);
}
const limit = query.limit ?? DEFAULT_PAGE_SIZE;
const after = query.nach ? decodeCursor(query.nach, 'bewegungen') : null;
const 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<ErpHarvestYears> {
const year = query.jahr;
const rows = await this.prisma.harvest.findMany({
where:
year !== undefined
? {
date: {
gte: new Date(Date.UTC(year, 0, 1)),
lt: new Date(Date.UTC(year + 1, 0, 1)),
},
}
: undefined,
select: { date: true, netWeight: true },
});
return {
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: new Date(),
years: summarizeHarvestYears(rows, year),
};
}
private envelope<T>(
page: Omit<ErpPage<T>, 'contractVersion' | 'generatedAt'>,
): ErpPage<T> {
return {
contractVersion: ERP_INVENTORY_CONTRACT_VERSION,
generatedAt: new Date(),
...page,
};
}
private async withRemaining(
batches: readonly BatchWithoutRemaining[],
): Promise<ErpBatch[]> {
const idsOf = (type: ErpBatchType) =>
batches.filter((b) => b.batchType === type).map((b) => b.id);
const bottlingIds = idsOf('BOTTLING');
const productionIds = idsOf('PRODUCTION');
const [bottlingRest, productionRest] = await Promise.all([
bottlingIds.length > 0
? this.stock.remainingByBottling(bottlingIds)
: new Map<string, number>(),
productionIds.length > 0
? this.stock.remainingByProductionBatch(productionIds)
: new Map<string, number>(),
]);
return batches.map((batch) => {
const rest =
batch.batchType === 'BOTTLING' ? bottlingRest : productionRest;
return {
...batch,
remainingQuantity: rest.get(batch.id) ?? batch.initialQuantity,
};
});
}
}
@@ -0,0 +1,96 @@
import { summarizeHarvestYears } from './harvest-years';
function harvest(date: string, netWeight: number | null) {
return { date: new Date(date), netWeight };
}
describe('summarizeHarvestYears', () => {
it('summiert das Nettogewicht je Kalenderjahr (UTC), älteste Jahre zuerst', () => {
const years = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 41),
harvest('2025-06-01T00:00:00.000Z', 12.5),
harvest('2026-05-20T00:00:00.000Z', 18.25),
]);
expect(years).toEqual([
{
year: 2025,
netWeightKg: 12.5,
harvestCount: 1,
harvestsWithoutNetWeight: 0,
},
{
year: 2026,
netWeightKg: 59.25,
harvestCount: 2,
harvestsWithoutNetWeight: 0,
},
]);
});
it('zählt Ernten ohne Nettogewicht mit, rechnet sie aber nicht ein', () => {
const [year] = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 20),
harvest('2026-08-01T00:00:00.000Z', null),
]);
expect(year).toEqual({
year: 2026,
netWeightKg: 20,
harvestCount: 2,
harvestsWithoutNetWeight: 1,
});
});
it('rundet auf Gramm, damit Gleitkomma-Reste nicht im Vertrag landen', () => {
const [year] = summarizeHarvestYears([
harvest('2026-07-14T00:00:00.000Z', 0.1),
harvest('2026-07-15T00:00:00.000Z', 0.2),
harvest('2026-07-16T00:00:00.000Z', 12.3456),
]);
expect(year.netWeightKg).toBe(12.646);
});
it('ordnet Silvester und Neujahr nach UTC zu', () => {
const years = summarizeHarvestYears([
harvest('2025-12-31T23:59:59.999Z', 1),
harvest('2026-01-01T00:00:00.000Z', 2),
]);
expect(years.map((y) => [y.year, y.netWeightKg])).toEqual([
[2025, 1],
[2026, 2],
]);
});
it('liefert für ein angefragtes Jahr ohne Ernten eine Nullzeile statt nichts', () => {
expect(summarizeHarvestYears([], 2024)).toEqual([
{
year: 2024,
netWeightKg: 0,
harvestCount: 0,
harvestsWithoutNetWeight: 0,
},
]);
});
it('beschränkt sich auf das angefragte Jahr', () => {
const years = summarizeHarvestYears(
[
harvest('2025-06-01T00:00:00.000Z', 5),
harvest('2026-06-01T00:00:00.000Z', 7),
],
2026,
);
expect(years).toEqual([
{
year: 2026,
netWeightKg: 7,
harvestCount: 1,
harvestsWithoutNetWeight: 0,
},
]);
});
it('liefert ohne Ernten und ohne Jahr eine leere Liste', () => {
expect(summarizeHarvestYears([])).toEqual([]);
});
});
@@ -0,0 +1,72 @@
/**
* Geerntete Honigmenge je Kalenderjahr - Grundlage für die Honigkosten je kg
* in ERPNext (Imkerei-Ausgaben des Jahres / geerntete kg).
*
* Gleiche Definition wie die Statistik in ApiCore (Honigertrag je Jahr):
* Summe der Nettogewichte (kg) der Ernten, Jahr aus dem Erntedatum. Das
* Jahr wird in UTC bestimmt, weil Erntedaten als Kalendertag (Mitternacht
* UTC) gespeichert werden. Ernten ohne Nettogewicht zählen nicht zur Menge,
* werden aber ausgewiesen - sonst sähe eine unvollständig erfasste Ernte
* wie ein schlechtes Jahr aus und die Kosten je kg wären zu hoch.
*/
export interface HarvestWeight {
date: Date;
/** Kilogramm; null, wenn nicht erfasst. */
netWeight: number | null;
}
export interface HarvestYear {
year: number;
/** Summe der Nettogewichte in kg, auf Gramm gerundet. */
netWeightKg: number;
harvestCount: number;
harvestsWithoutNetWeight: number;
}
const GRAMS_PER_KG = 1000;
function roundToGrams(kg: number): number {
return Math.round(kg * GRAMS_PER_KG) / GRAMS_PER_KG;
}
function emptyYear(year: number): HarvestYear {
return {
year,
netWeightKg: 0,
harvestCount: 0,
harvestsWithoutNetWeight: 0,
};
}
/**
* Fasst Ernten je Jahr zusammen, älteste Jahre zuerst. Mit `onlyYear` nur
* dieses Jahr, und zwar auch dann, wenn es keine Ernte hat (Nullzeile).
*/
export function summarizeHarvestYears(
harvests: readonly HarvestWeight[],
onlyYear?: number,
): HarvestYear[] {
const byYear = new Map<number, HarvestYear>();
if (onlyYear !== undefined) {
byYear.set(onlyYear, emptyYear(onlyYear));
}
for (const harvest of harvests) {
const year = harvest.date.getUTCFullYear();
if (onlyYear !== undefined && year !== onlyYear) continue;
const current = byYear.get(year) ?? emptyYear(year);
byYear.set(year, {
year,
netWeightKg: current.netWeightKg + (harvest.netWeight ?? 0),
harvestCount: current.harvestCount + 1,
harvestsWithoutNetWeight:
current.harvestsWithoutNetWeight + (harvest.netWeight === null ? 1 : 0),
});
}
return [...byYear.values()]
.sort((a, b) => a.year - b.year)
.map((entry) => ({
...entry,
netWeightKg: roundToGrams(entry.netWeightKg),
}));
}
+664
View File
@@ -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<T> {
contractVersion: number;
generatedAt: string;
items: T[];
nextCursor: string | null;
hasMore: boolean;
settledUntil?: string;
}
type Item = Record<string, unknown> & { id: string };
interface HarvestYearsBody {
contractVersion: number;
years: {
year: number;
netWeightKg: number;
harvestCount: number;
harvestsWithoutNetWeight: number;
}[];
}
const BASE = '/api/v1/export/inventar';
const ENDPOINTS = ['produkte', 'chargen', 'bewegungen', 'ernten'] as const;
/**
* Inventar-Spiegel für ERPNext (T8): nur lesend, nur mit erp-inventory:read,
* nur freigegebene Felder, stabil und seitenweise. Der ERPNext-Importer wird
* nach docs/erp-inventar-export.md gebaut und verlässt sich auf diese Form.
*/
describe('ERP-Inventar-Export (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let adminToken: string;
let readonlyToken: string;
let erpKey: string;
let shopKey: string;
let honeyProductId: string;
let candleProductId: string;
let bottlingId: string;
let productionBatchId: string;
// Laufeigener Zeitraum weit in der Vergangenheit: so finden sich die
// Bewegungen dieses Laufs zwischen denen anderer Läufe und Tests.
const movementBase = Date.UTC(2001, 0, 1) + (Date.now() % 1_000_000_000);
const at = (seconds: number) => new Date(movementBase + seconds * 1000);
const movementIds: Record<string, string> = {};
const server = () => app.getHttpServer();
// Jeder Test tritt als eigener Client auf (X-Forwarded-For, im Test wird
// ein Proxy-Hop vertraut): diese Datei stellt mehr Anfragen, als der
// globale Throttler einer einzelnen Adresse je Minute erlaubt.
let clientCounter = 0;
let clientIp = '198.51.100.1';
beforeEach(() => {
clientCounter += 1;
clientIp = `198.51.100.${clientCounter}`;
});
function get(path: string, token: string, ip = clientIp) {
return request(server())
.get(`${BASE}/${path}`)
.set('Authorization', `Bearer ${token}`)
.set('X-Forwarded-For', ip);
}
async function createKey(scopes: string[]): Promise<CreatedKeyBody> {
const response = await request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: `ERP-Test ${suffix}`, scopes })
.expect(201);
return response.body as CreatedKeyBody;
}
async function allItems(path: string): Promise<Item[]> {
const items: Item[] = [];
let nach: string | null = null;
for (let page = 0; page < 20; page++) {
const query = nach ? `?limit=500&nach=${nach}` : '?limit=500';
const body = (await get(`${path}${query}`, erpKey).expect(200))
.body as PageBody<Item>;
items.push(...body.items);
if (!body.hasMore) return items;
nach = body.nextCursor;
}
throw new Error('zu viele Seiten');
}
beforeAll(async () => {
app = await createTestApp();
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<PrismaService['batchMovement']['create']>[0]['data'],
) => {
movementIds[key] = (await prisma.batchMovement.create({ data })).id;
};
const personal = {
notes: 'an Nachbar Müller verschenkt',
userId: owner.id,
};
await book('hofladen', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -6,
createdAt: at(1),
...personal,
});
await book('eigenbedarf', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'EIGENBEDARF',
quantity: -2,
createdAt: at(2),
...personal,
});
await book('verkauf', {
bottlingBatchId: bottlingId,
type: 'SHOP_SALE',
quantity: -3,
createdAt: at(2),
});
await book('bruch', {
productionBatchId,
type: 'WITHDRAWAL',
reason: 'BRUCH',
quantity: -1,
createdAt: at(3),
...personal,
});
await book('korrektur', {
bottlingBatchId: bottlingId,
type: 'CORRECTION',
quantity: 1,
createdAt: at(4),
...personal,
});
await book('rohstoff', {
rawMaterialLotId: rawLot.id,
type: 'WITHDRAWAL',
reason: 'SONSTIGES',
quantity: -10,
createdAt: at(5),
});
// Gerade eben gebucht: noch innerhalb der Wartezeit.
await book('frisch', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'PROBE',
quantity: -1,
});
});
afterAll(async () => {
await app.close();
});
describe('Zugang', () => {
it.each(ENDPOINTS)('%s ist mit dem ERP-Schlüssel lesbar', async (path) => {
await get(path, erpKey).expect(200);
});
it.each(ENDPOINTS)('%s verlangt einen Schlüssel (401)', async (path) => {
await request(server()).get(`${BASE}/${path}`).expect(401);
});
it('lehnt fehlerhafte und unbekannte Schlüssel ab (401)', async () => {
await get('produkte', 'ack_nope').expect(401);
await get('produkte', `ack_abcd1234_${'Z'.repeat(43)}`).expect(401);
});
it.each(ENDPOINTS)(
'%s bleibt dem Shop-Schlüssel verschlossen (403)',
async (path) => {
await get(path, shopKey).expect(403);
},
);
it('verweigert Benutzern ohne das Recht den Export (403)', async () => {
await get('produkte', readonlyToken).expect(403);
});
it('öffnet dem ERP-Schlüssel nichts außer dem Export', async () => {
const auth = { Authorization: `Bearer ${erpKey}` };
for (const path of [
'/api/v1/shop-sync',
'/api/v1/shop-sync/stock',
'/api/v1/products',
'/api/v1/harvests',
'/api/v1/bottling-batches',
'/api/v1/export/colonies.csv',
'/api/v1/users',
'/api/v1/auth/me',
]) {
await request(server()).get(path).set(auth).expect(403);
}
await request(server())
.post('/api/v1/shop-sync/movements')
.set(auth)
.send({ requests: [] })
.expect(403);
});
it('sperrt einen widerrufenen Schlüssel sofort (401)', async () => {
const created = await createKey(['erp-inventory:read']);
await get('produkte', created.token).expect(200);
await request(server())
.post(`/api/v1/api-keys/${created.apiKey.id}/revoke`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
await get('produkte', created.token).expect(401);
});
});
describe('Produkte', () => {
it('liefert nur die freigegebenen Felder, ohne Beschreibung', async () => {
const items = await allItems('produkte');
const honey = items.find((p) => p.id === honeyProductId);
const candle = items.find((p) => p.id === candleProductId);
expect(Object.keys(honey ?? {}).sort()).toEqual(
[
'id',
'kind',
'name',
'packagingSize',
'salesUnit',
'shopCategory',
'sku',
'status',
'updatedAt',
'weight',
].sort(),
);
expect(honey).toMatchObject({
name: `Sommertracht 500 g ${suffix}`,
sku: `ERP-${suffix}`,
kind: 'BOTTLED_HONEY',
shopCategory: 'HONIG',
status: 'ACTIVE',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
});
expect(candle).toMatchObject({
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
});
expect(JSON.stringify(items)).not.toContain('Lieferant Gläser Huber');
});
it('blättert stabil nach id', async () => {
const first = (await get('produkte?limit=1', erpKey).expect(200))
.body as PageBody<Item>;
expect(first.contractVersion).toBe(1);
expect(first.items).toHaveLength(1);
expect(first.hasMore).toBe(true);
const second = (
await get(`produkte?limit=1&nach=${first.nextCursor}`, erpKey).expect(
200,
)
).body as PageBody<Item>;
expect(second.items).toHaveLength(1);
expect(second.items[0].id > first.items[0].id).toBe(true);
});
});
describe('Chargen', () => {
it('liefert Abfüllung und Herstellungscharge mit Rest aus dem Bewegungsbuch', async () => {
const items = await allItems('chargen');
const bottling = items.find((b) => b.id === bottlingId);
const production = items.find((b) => b.id === productionBatchId);
expect(bottling).toEqual({
id: bottlingId,
batchType: 'BOTTLING',
lotNumber: `L-ERP-${suffix}`,
productId: honeyProductId,
date: '2026-08-01T00:00:00.000Z',
bestBeforeDate: '2028-08-01T00:00:00.000Z',
initialQuantity: 60,
// 60 - 6 Hofladen - 2 Eigenbedarf - 3 Shop + 1 Inventur - 1 Probe
remainingQuantity: 49,
fillWeightGrams: 500,
honeyType: 'Sommertracht',
});
expect(production).toEqual({
id: productionBatchId,
batchType: 'PRODUCTION',
lotNumber: `K-ERP-${suffix}`,
productId: candleProductId,
date: '2026-09-01T00:00:00.000Z',
bestBeforeDate: null,
initialQuantity: 20,
remainingQuantity: 19,
fillWeightGrams: null,
honeyType: null,
});
// Abfüllungen vor Herstellungschargen.
const kinds = items.map((b) => b.batchType);
expect(kinds.lastIndexOf('BOTTLING')).toBeLessThan(
kinds.indexOf('PRODUCTION'),
);
const raw = JSON.stringify(items);
expect(raw).not.toContain('Docht von Firma Meier');
expect(raw).not.toContain('Erika Imkerin');
// Rohstoff-Chargen sind keine Ware.
expect(raw).not.toContain(`W-ERP-${suffix}`);
});
it('blättert ohne Lücken und Dubletten', async () => {
const all = (await allItems('chargen')).map((b) => b.id);
const limit = Math.max(1, Math.ceil(all.length / 3));
const seen: string[] = [];
let nach: string | null = null;
for (let page = 0; page < 20; page++) {
const query: string = nach
? `?limit=${limit}&nach=${nach}`
: `?limit=${limit}`;
const body = (await get(`chargen${query}`, erpKey).expect(200))
.body as PageBody<Item>;
expect(body.items.length).toBeLessThanOrEqual(limit);
seen.push(...body.items.map((b) => b.id));
nach = body.nextCursor;
if (!body.hasMore) break;
}
expect(new Set(seen).size).toBe(seen.length);
expect(seen).toEqual(
expect.arrayContaining([bottlingId, productionBatchId]),
);
// Andere Testdateien legen parallel Chargen an oder löschen welche:
// verglichen wird die Reihenfolge der Chargen, die beide Abrufe sahen.
const inBoth = new Set(all.filter((id) => seen.includes(id)));
expect(seen.filter((id) => inBoth.has(id))).toEqual(
all.filter((id) => inBoth.has(id)),
);
});
});
describe('Bewegungen', () => {
async function myMovements(): 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<Item>;
found.push(...body.items.filter((m) => mine.has(m.id)));
const last = body.items[body.items.length - 1];
const passedMine =
last !== undefined &&
new Date(last.createdAt as string).getTime() > at(5).getTime();
if (!body.hasMore || passedMine) {
return { items: found, cursor: body.nextCursor! };
}
query = `bewegungen?limit=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<Item>;
const settled = new Date(body.settledUntil!).getTime();
expect(settled).toBeLessThanOrEqual(before - 4 * 60 * 1000);
expect(settled).toBeGreaterThan(before - 6 * 60 * 1000);
});
it('setzt mit dem letzten Cursor fort, ohne etwas doppelt zu liefern', async () => {
const { cursor } = await myMovements();
const body = (
await get(`bewegungen?limit=500&nach=${cursor}`, erpKey).expect(200)
).body as PageBody<Item>;
const mine = new Set(Object.values(movementIds));
// Nur Bewegungen hinter dem Cursor - die eigenen lagen davor.
expect(body.items.filter((m) => mine.has(m.id))).toEqual([]);
});
});
describe('Parameter', () => {
it.each([
['produkte?limit=0'],
['produkte?limit=501'],
['produkte?limit=zwei'],
['produkte?nach=kaputt!'],
['produkte?seite=2'],
['chargen?nach=bm90LWpzb24'],
['bewegungen?seit=gestern'],
['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);
});
});
});
+12
View File
@@ -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] ~~Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht~~ – nicht umsetzbar in diesem Kontext: erfordert Spracherkennungs-/LLM-Infrastruktur über das bestehende Stack hinaus
- [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware - [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware
## ERP-Anbindung
### T8. Inventar-Spiegel für ERPNext (Datenquelle)
- Backend: `ErpExportModule` – nur lesend, Recht `erp-inventory:read` (eigener API-Schlüssel-Scope), Fehlversuchs-Sperre für API-Schlüssel je 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üfung September 2026
Prüfmatrix, Verifikationsstand und offene Entscheidungen: [`docs/pruefung-2026-09.md`](docs/pruefung-2026-09.md). Prüfmatrix, Verifikationsstand und offene Entscheidungen: [`docs/pruefung-2026-09.md`](docs/pruefung-2026-09.md).
+358
View File
@@ -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://<apicore-host>/api/v1/export/inventar`
- Antworten: JSON (UTF-8), `Cache-Control: no-store`.
- Zeitpunkte: ISO 8601 in UTC mit Millisekunden, z. B. `2026-10-01T08:15:30.123Z`. Kalendertage (Abfülldatum, MHD, Erntedatum) stehen als Mitternacht UTC, z. B. `2026-08-01T00:00:00.000Z`; der Tag ist der Datumsteil.
- Kennungen (`id`, `productId`, `batchId`) sind undurchsichtige Zeichenketten (cuid), stabil über die Lebensdauer des Datensatzes.
- Mengen in **Stück** (Gläser bzw. hergestellte Stück) sind ganze Zahlen; Gewichte stehen mit Einheit im Feldnamen (`fillWeightGrams`, `netWeightKg`).
- Unbekannte Query-Parameter werden mit `400` abgelehnt.
### Seitenweises Lesen
`produkte`, `chargen` und `bewegungen` liefern Seiten in einer gemeinsamen Hülle:
| Feld | Typ | Bedeutung |
|---|---|---|
| `contractVersion` | number | Vertragsversion, derzeit `1` |
| `generatedAt` | string (Zeitpunkt) | Zeitpunkt der Antwort |
| `items` | array | Einträge dieser Seite, stabil sortiert |
| `nextCursor` | string \| null | Position hinter dem letzten Eintrag. Bei leerer Seite der übergebene `nach`-Wert (oder `null`, wenn keiner übergeben wurde). |
| `hasMore` | boolean | `true`, wenn hinter dieser Seite weitere Einträge liegen |
| Parameter | Typ | Bedeutung |
|---|---|---|
| `limit` | Ganzzahl 1–500, Vorgabe 100 | Einträge je Seite |
| `nach` | Cursor | `nextCursor` der vorigen Antwort, unverändert. Undurchsichtig (base64url); nur von dem Endpunkt gültig, der ihn ausgegeben hat. |
Ablauf: ohne `nach` beginnen, dann mit `nach=<nextCursor>` weiterlesen, solange `hasMore` `true` ist.
`produkte` und `chargen` sind Momentaufnahmen und werden in jedem Lauf vollständig gelesen. Ändert sich zwischen zwei Seiten ein Bestand, gleicht der nächste Lauf das aus. Bei `bewegungen` speichert der Importer den letzten `nextCursor` und liest im nächsten Lauf dort weiter.
## 3. `GET /produkte` – Artikel
Alle ApiCore-Artikel (auch eingestellte), sortiert nach `id` aufsteigend.
| Feld | Typ | Bedeutung |
|---|---|---|
| `id` | string | ApiCore-Produkt-ID |
| `name` | string | Bezeichnung |
| `sku` | string \| null | Artikelnummer in ApiCore (eindeutig, optional) |
| `kind` | `BOTTLED_HONEY` \| `MANUFACTURED` | Wo der Bestand liegt: Honig je Abfüllung, andere Waren (Kerzen, Propolis …) je Herstellungscharge |
| `shopCategory` | `HONIG` \| `KERZEN` \| `PROPOLIS` \| `SONSTIGES` | Kategorie/Sorte des Artikels |
| `status` | `ACTIVE` \| `DISCONTINUED` | eingestellt = nicht mehr hergestellt; Restbestände können noch existieren |
| `packagingSize` | number \| null | Gebindegröße, Einheit in ApiCore nicht festgelegt; Konvention **Gramm** (wie der Wabenhain-Import annimmt) |
| `weight` | number \| null | Gewicht, gleiche Einheiten-Konvention |
| `salesUnit` | string \| null | Verkaufseinheit, z. B. `Glas` |
| `updatedAt` | string (Zeitpunkt) | letzte Änderung am Artikel |
Für Honig ist die verlässliche Füllmenge `fillWeightGrams` an der Charge (siehe unten), nicht `packagingSize`.
**Verknüpfung zu den Shop-Artikeln `WH-<slug>`** (Wabenhain kennt die ApiCore-IDs):
- Honig: Wabenhain führt ein Shop-Produkt je Abfüllung; `Product.apicoreBottlingId` = `chargen[].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=<Startdatum>`, danach immer mit dem gespeicherten `nextCursor`. Derselbe Cursor liefert bei erneutem Abruf dieselben Bewegungen (plus inzwischen hinzugekommene); der Importer entdoppelt trotzdem über `id`.
## 6. `GET /ernten` – Erntemengen je Jahr
Grundlage für die Honigkosten je kg (Imkerei-Ausgaben des Jahres / geerntete kg). Nicht seitenweise (eine Zeile je Jahr).
| Parameter | Typ | Bedeutung |
|---|---|---|
| `jahr` | Ganzzahl 1900–2100, optional | nur dieses Kalenderjahr; ohne Angabe alle Jahre mit Ernten |
| Feld | Typ | Bedeutung |
|---|---|---|
| `contractVersion` | number | `1` |
| `generatedAt` | string (Zeitpunkt) | Zeitpunkt der Antwort |
| `years[].year` | Ganzzahl | Kalenderjahr (aus dem Erntedatum, UTC) |
| `years[].netWeightKg` | number | Summe der Nettogewichte in **kg**, auf Gramm (3 Nachkommastellen) gerundet |
| `years[].harvestCount` | Ganzzahl | Anzahl Ernten im Jahr |
| `years[].harvestsWithoutNetWeight` | Ganzzahl | davon ohne erfasstes Nettogewicht (zählen nicht zur Menge) |
Definition wie die ApiCore-Statistik „Honigertrag je Jahr“: Summe `netWeight` der Ernten mit Erntedatum im Jahr. Mit `jahr` kommt immer genau eine Zeile, bei einem Jahr ohne Ernten mit Nullen. Ist `harvestsWithoutNetWeight > 0`, sind die Kosten je kg zu hoch – der Importer sollte das im Cockpit anzeigen. Standorte, Völker und Notizen sind nicht enthalten.
## 7. Fehler
Fehlerantworten sind JSON mit `statusCode` und `message` (bei Validierungsfehlern `message` als Liste).
| Status | Wann | Verhalten des Importers |
|---|---|---|
| `400` | `limit` außerhalb 1–500 oder keine Ganzzahl; `nach` kein gültiger Cursor oder von einem anderen Endpunkt; `seit` kein ISO-Zeitpunkt; `seit` und `nach` zusammen; `jahr` außerhalb 1900–2100; unbekannter Parameter | Programmfehler – Schritt abbrechen, im Cockpit melden |
| `401` | Schlüssel fehlt, ist fehlerhaft, unbekannt, widerrufen oder abgelaufen | Schritt abbrechen; Hinweis „ApiCore-Schlüssel prüfen“ |
| `403` | Schlüssel ohne `erp-inventory:read`, oder IP-Adresse nicht in der Allowlist des Schlüssels | Schritt abbrechen; Hinweis „Recht bzw. Adressen am Schlüssel prüfen“ |
| `429` | zu viele Fehlversuche oder Anfragen von dieser Adresse | Schritt abbrechen, im nächsten Lauf erneut |
| `5xx` / keine Antwort | Störung in ApiCore | Schritt abbrechen, im nächsten Lauf erneut |
Ein fehlgeschlagener Abruf ist nie „leer“: der Importer setzt dann keine Bestände auf 0 und schiebt keinen Cursor weiter. Die anderen Schritte des Abgleich-Jobs laufen weiter.
## 8. Beispiele
Alle Werte synthetisch.
```http
GET /api/v1/export/inventar/produkte?limit=2
Authorization: Bearer <ERPNEXT_APICORE_KEY>
```
```json
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:04.512Z",
"items": [
{
"id": "cmg0honig500sommer01",
"name": "Sommertracht 500 g",
"sku": "HON-SO-500",
"kind": "BOTTLED_HONEY",
"shopCategory": "HONIG",
"status": "ACTIVE",
"packagingSize": 500,
"weight": 500,
"salesUnit": "Glas",
"updatedAt": "2026-09-02T07:41:10.000Z"
},
{
"id": "cmg0kerze0bienen0001",
"name": "Bienenwachskerze klein",
"sku": null,
"kind": "MANUFACTURED",
"shopCategory": "KERZEN",
"status": "ACTIVE",
"packagingSize": null,
"weight": 120,
"salesUnit": "Stück",
"updatedAt": "2026-09-20T15:03:55.000Z"
}
],
"nextCursor": "eyJrIjoicHJvZHVrdGUiLCJpZCI6ImNtZzBrZXJ6ZTBiaWVuZW4wMDAxIn0",
"hasMore": true
}
```
```http
GET /api/v1/export/inventar/chargen?limit=500
```
```json
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:05.130Z",
"items": [
{
"id": "cmg0abf0sommer2026a1",
"batchType": "BOTTLING",
"lotNumber": "L-2026-014",
"productId": "cmg0honig500sommer01",
"date": "2026-08-01T00:00:00.000Z",
"bestBeforeDate": "2028-08-01T00:00:00.000Z",
"initialQuantity": 60,
"remainingQuantity": 49,
"fillWeightGrams": 500,
"honeyType": "Sommertracht"
},
{
"id": "cmg0her0kerze2026b01",
"batchType": "PRODUCTION",
"lotNumber": "K-2026-003",
"productId": "cmg0kerze0bienen0001",
"date": "2026-09-01T00:00:00.000Z",
"bestBeforeDate": null,
"initialQuantity": 20,
"remainingQuantity": 19,
"fillWeightGrams": null,
"honeyType": null
}
],
"nextCursor": "eyJrIjoiY2hhcmdlbiIsInQiOiJQIiwiaWQiOiJjbWcwaGVyMGtlcnplMjAyNmIwMSJ9",
"hasMore": false
}
```
```http
GET /api/v1/export/inventar/bewegungen?seit=2026-01-01T00:00:00Z&limit=3
```
```json
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:05.877Z",
"settledUntil": "2026-10-13T01:55:05.877Z",
"items": [
{
"id": "cmg0bew000000000001",
"createdAt": "2026-09-05T16:20:11.402Z",
"batchType": "BOTTLING",
"batchId": "cmg0abf0sommer2026a1",
"lotNumber": "L-2026-014",
"productId": "cmg0honig500sommer01",
"type": "WITHDRAWAL",
"reason": "HOFLADEN",
"quantity": -6
},
{
"id": "cmg0bew000000000002",
"createdAt": "2026-09-06T09:02:47.118Z",
"batchType": "BOTTLING",
"batchId": "cmg0abf0sommer2026a1",
"lotNumber": "L-2026-014",
"productId": "cmg0honig500sommer01",
"type": "SHOP_SALE",
"reason": null,
"quantity": -3
},
{
"id": "cmg0bew000000000003",
"createdAt": "2026-09-12T18:44:03.950Z",
"batchType": "BOTTLING",
"batchId": "cmg0abf0sommer2026a1",
"lotNumber": "L-2026-014",
"productId": "cmg0honig500sommer01",
"type": "WITHDRAWAL",
"reason": "EIGENBEDARF",
"quantity": -2
}
],
"nextCursor": "eyJrIjoiYmV3ZWd1bmdlbiIsInRzIjoiMjAyNi0wOS0xMlQxODo0NDowMy45NTBaIiwiaWQiOiJjbWcwYmV3MDAwMDAwMDAwMDAzIn0",
"hasMore": true
}
```
```http
GET /api/v1/export/inventar/ernten?jahr=2026
```
```json
{
"contractVersion": 1,
"generatedAt": "2026-10-13T02:00:06.240Z",
"years": [
{
"year": 2026,
"netWeightKg": 184.35,
"harvestCount": 7,
"harvestsWithoutNetWeight": 1
}
]
}
```
```json
{ "statusCode": 400, "message": "Ungültiger Cursor - bitte nextCursor der vorigen Seite unverändert als \"nach\" übergeben", "error": "Bad Request" }
```
## 9. Was nicht enthalten ist
Bewusst nicht im Export (Feld-Allowlist im Code, `src/erp-export/erp-export.service.ts`): Notizen an Chargen und Bewegungen, Benutzer (wer gebucht hat), API-Schlüssel und Shop-Anfrage-Kennungen, Artikelbeschreibungen (in ApiCore interne Notizen), Standorte, Völker, Behandlungen, Rohstoffe. Neue Felder an den ApiCore-Modellen erscheinen erst, wenn sie hier ausdrücklich ergänzt werden.
## 10. Versionierung
`contractVersion` steigt nur bei inkompatiblen Änderungen (Feld entfernt, umbenannt oder in der Bedeutung geändert). Neue Felder, neue Enum-Werte und neue Endpunkte kommen ohne Versionswechsel; der Importer ignoriert unbekannte Felder und bricht bei einer anderen `contractVersion` ab, ohne etwas zu ändern.
## 11. Rollout
1. ApiCore ausrollen. Keine Datenbank-Migration nötig: das Recht `erp-inventory:read` legt ApiCore beim Start an und gibt es der Systemrolle Administrator.
2. Schlüssel anlegen (Abschnitt 1).
3. Schlüssel als Kubernetes-Secret für den ERPNext-Abgleich ablegen, dazu die Basis-URL (`…/api/v1`) als Konfiguration.
4. Erreichbarkeit: wie der Wabenhain-Shop über die öffentliche ApiCore-Adresse. Dann ist die Absender-IP die Ausgangsadresse des Clusters, und die Allowlist des Schlüssels kann genau diese Adresse enthalten. Ein Aufruf über den clusterinternen Service ginge mit wechselnder Pod-Adresse ein und bräuchte eine NetworkPolicy-Freigabe; die Allowlist taugt dann nur noch als Netzbereich.
5. Probeabruf aller vier Endpunkte, dann den nächtlichen Schritt aktivieren.