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:
1 parent
1a1e70c94b
commit
68f81fabd4
17 files changed
+2569
-4
No files matched your search
+3
-2
@@ -26,6 +26,7 @@ Ein NestJS-Modul pro fachlichem Bereich, Reihenfolge entspricht Baureihenfolge i
|
||||
| `DashboardModule` | Aggregations-Endpunkte | alle o.g. |
|
||||
| `SearchModule` | globale Suche, Filter-Bausteine | alle o.g. |
|
||||
| `ExportModule` | CSV/JSON/PDF-Export | alle o.g. |
|
||||
| `ErpExportModule` | Nur lesender Inventar-Spiegel für ERPNext (`/api/v1/export/inventar/...`, Recht `erp-inventory:read`), Vertrag in [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md) | Products, Bottling, ProductionBatches, Stock, Harvests |
|
||||
|
||||
Jedes Modul: `*.controller.ts`, `*.service.ts`, `dto/`, `*.module.ts`, `*.spec.ts` (Unit), `test/*.e2e-spec.ts` (Integration). Rollenprüfung über `@Roles()`-Decorator + `RolesGuard`, angewendet pro Endpunkt – niemals nur UI-seitig.
|
||||
|
||||
@@ -102,7 +103,7 @@ Fachliche Annahmen, getroffen weil das Lastenheft an der Stelle keine eindeutige
|
||||
|
||||
11. **Idempotenz per Opt-in**: Anlege-Endpunkte, die Formulare und die Offline-Warteschlange wiederholen können (Durchsicht, Behandlung, Fütterung, Varroa, Schwarmkontrolle, Ernte, Aufgabe, Abfüllung, Lagerbewegung), tragen `@Idempotent()`. Der Client sendet je Vorgang einen `Idempotency-Key`; eine Wiederholung liefert die gespeicherte erste Antwort (`IdempotencyRecord`, 90 Tage). Bewusst kein globaler Interceptor, da gespeicherte Antworten sonst Geheimnisse (MFA-Setup, Tokens) enthalten könnten. Die Tabelle ist flüchtig und nicht Teil des Backups.
|
||||
12. **Automatische Aufgaben sind Vorschläge**: `Task.sourceAutomationTrigger` kennzeichnet von Regeln erzeugte Aufgaben. Je Regel und Objekt existiert höchstens eine offene Aufgabe. Keine voreingestellte Frist (fachlich nicht belastbar ableitbar); optional `config.dueInDays` je Regel. Die Varroa-Schwelle (Milben/Tag) wird nur mit Tagesraten des natürlichen Milbenfalls verglichen.
|
||||
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`, derzeit nur `shop-sync:read`). Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
|
||||
14. **API-Schlüssel für Maschinen-Zugriffe**: Integrationen (Wabenhain-Shop) nutzen einen Schlüssel `ack_…` im `Authorization: Bearer`-Header statt eines Service-Kontos mit Passwort. Gespeichert wird nur der SHA-256-Hash (32 Zufallsbytes, daher kein langsamer KDF nötig); der Klartext erscheint einmal bei der Erstellung. Ein Schlüssel ist kein Benutzer: `JwtAuthGuard` setzt `request.apiKey` statt `request.user`, `PermissionsGuard` lässt ihn nur auf Routen, deren Berechtigung in seinen Scopes steht – `@AnyAuthenticatedUser`-Routen nie. Erlaubte Scopes sind eine kurze Allowlist (`api-keys/api-key-scopes.ts`: `shop-sync:read`, `shop-sync:write`, `erp-inventory:read`); je angebundenem System ein eigener Schlüssel. Verwaltung mit `api-keys:manage`. Schlüssel sind bewusst nicht Teil des Backups: nach einer Wiederherstellung wird ein neuer Schlüssel ausgestellt.
|
||||
13. **Lebende vs. beendete Völker**: Nur `DISSOLVED`, `SOLD`, `DECEASED`, `MERGED` sperren Umsetzen/Teilen/Vereinigen/Königinzuweisung (`colonies/colony-status.ts`). `WEAK`, `CONSPICUOUS`, `QUEENLESS` bleiben bearbeitbar.
|
||||
15. **Bestandsbuch aus Behandlungen, nicht als eigenes Register**: Das Bestandsbuch nach Art. 108 VO (EU) 2019/6 wird aus `Treatment` erzeugt (`export/treatment-log.ts`); Lieferant, Erwerbsbeleg und verschreibender Tierarzt sind Freitextfelder der Behandlung, Name/Anschrift des Halters liegen in `SiteSettings`. Der Stand am Behandlungstag wird beim Anlegen als `Treatment.apiaryId` festgehalten (Umsetzen ändert ihn nicht nachträglich); für Altbestand wird er aus `ColonyApiaryHistory` abgeleitet und im PDF bei Unsicherheit markiert. Fehlende Pflichtangaben werden als „FEHLT“ gedruckt statt still weggelassen. Keine Wartezeit-Automatik (siehe 10).
|
||||
16. **Überwinterung als eigene Einträge statt Volksstatus**: `WinterCheck` (eindeutig je Volk, `winterYear`, Saison) hält Ein- und Auswinterung; ab Juli gehört ein Datum zum Winter desselben Jahres. Verlustursachen sind bewusst nur die COLOSS-Kategorien, keine Diagnosen. Die Übersicht gruppiert nach dem aktuellen Stand des Volkes. Ein Verlust ändert den Volksstatus nur auf ausdrücklichen Wunsch (`markColonyDeceased`). Neue Rechte vergibt die Migration an Nicht-System-Rollen, weil der Katalog-Sync nur System-Rollen berechtigt.
|
||||
@@ -142,7 +143,7 @@ Grundlage: OWASP API Security Top 10 (2023) und OWASP ASVS Level 2. Umsetzung un
|
||||
| Sitzungen | Refresh-Rotation atomar, Diebstahlerkennung, max. 7 Tage; Einmal-Tokens für SSO-Abschluss und MFA-Schritt |
|
||||
| Step-up | Backup-Import, MFA ein/aus verlangen aktuelles Passwort (+ TOTP) |
|
||||
| Autorisierung | fail-closed `PermissionsGuard`; keine Rechteausweitung über Benutzer-/Rollenpflege; Suche nur über lesbare Objektarten |
|
||||
| Maschinen-Zugriff | API-Schlüssel nur als Hash, Scope-Allowlist (`shop-sync:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben |
|
||||
| Maschinen-Zugriff | API-Schlüssel nur als Hash (Suche über den Hash, kein Vergleich von Geheimnissen), Scope-Allowlist (`shop-sync:read`, `shop-sync:write`, `erp-inventory:read`), IP-Allowlist, widerrufbar, befristbar; Ersteller kann nur eigene Rechte vergeben; nach 20 abgelehnten Schlüsseln in 15 min je IP `429`; Logs nur mit Schlüssel-Präfix |
|
||||
| Ausgehende Aufrufe | Webhooks: Ziel-IP beim Verbindungsaufbau geprüft (kein DNS-Rebinding), nur Port 80/443, https in Produktion, 5 s Timeout, Signatur über Zeitstempel+ID+Body |
|
||||
| Transport/Antworten | `Cache-Control: no-store` (außer öffentliche Chargen-Seite), CSP `default-src 'none'`, `frame-ancestors 'none'`, keine ETags; Produktion startet nur mit https-`FRONTEND_ORIGIN` und sicheren Cookies |
|
||||
| Rate-Limits | Throttler vor der Authentifizierung (100/min, Login/MFA/Refresh/Logout 10/min, öffentliche Chargen 30/min); Client-IP über `TRUST_PROXY` |
|
||||
|
||||
@@ -6,12 +6,15 @@ import { PermissionKey } from '../auth/permissions/permission-catalog';
|
||||
* only ever gets what a machine integration needs. shop-sync:write lets the
|
||||
* shop book its own sales and returns - nothing else - and every booking is
|
||||
* idempotent and kept in the ledger, so a leaked key cannot erase history.
|
||||
* Removing an entry here also takes it away from existing keys (see
|
||||
* ApiKeysService.authenticate).
|
||||
* erp-inventory:read lets ERPNext read the inventory mirror (articles,
|
||||
* batches with remaining stock, stock movements, harvest totals) - read
|
||||
* only, no people, no notes. Removing an entry here also takes it away from
|
||||
* existing keys (see ApiKeysService.authenticate).
|
||||
*/
|
||||
export const API_KEY_SCOPES = [
|
||||
'shop-sync:read',
|
||||
'shop-sync:write',
|
||||
'erp-inventory:read',
|
||||
] as const satisfies readonly PermissionKey[];
|
||||
|
||||
export type ApiKeyScope = (typeof API_KEY_SCOPES)[number];
|
||||
|
||||
@@ -48,6 +48,7 @@ import { ProfileModule } from './profile/profile.module';
|
||||
import { SiteSettingsModule } from './site-settings/site-settings.module';
|
||||
import { BackupModule } from './backup/backup.module';
|
||||
import { ShopSyncModule } from './shop-sync/shop-sync.module';
|
||||
import { ErpExportModule } from './erp-export/erp-export.module';
|
||||
import { ApiKeysModule } from './api-keys/api-keys.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
import { PermissionsGuard } from './auth/guards/permissions.guard';
|
||||
@@ -106,6 +107,7 @@ import { IdempotencyCleanupService } from './common/idempotency/idempotency-clea
|
||||
SiteSettingsModule,
|
||||
BackupModule,
|
||||
ShopSyncModule,
|
||||
ErpExportModule,
|
||||
ApiKeysModule,
|
||||
],
|
||||
controllers: [],
|
||||
|
||||
@@ -361,6 +361,15 @@ export const PERMISSION_CATALOG = [
|
||||
[['manage', 'Anlegen, ansehen und widerrufen']],
|
||||
3400,
|
||||
),
|
||||
// Like shop-sync: a narrow read-only allowlist for one machine consumer
|
||||
// (ERPNext's nightly inventory mirror), separate from products:read etc.,
|
||||
// which return descriptions and full records.
|
||||
...group(
|
||||
'ERP-Anbindung',
|
||||
'erp-inventory',
|
||||
[['read', 'Inventar-Spiegel für ERPNext lesen']],
|
||||
3500,
|
||||
),
|
||||
] as const satisfies readonly PermissionCatalogEntry[];
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -292,6 +292,18 @@ Software-seitig umsetzbare Punkte werden gebaut. Punkte, die dedizierte native B
|
||||
- [x] ~~Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht~~ – nicht umsetzbar in diesem Kontext: erfordert Spracherkennungs-/LLM-Infrastruktur über das bestehende Stack hinaus
|
||||
- [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware
|
||||
|
||||
## ERP-Anbindung
|
||||
|
||||
### T8. Inventar-Spiegel für ERPNext (Datenquelle)
|
||||
|
||||
- Backend: `ErpExportModule` – nur lesend, Recht `erp-inventory:read` (eigener API-Schlüssel-Scope), Fehlversuchs-Sperre für API-Schlüssel je IP
|
||||
- Endpunkte: `GET /api/v1/export/inventar/produkte`, `.../chargen`, `.../bewegungen?seit=&nach=&limit=`, `.../ernten?jahr=`
|
||||
- Frontend: Berechtigungen beim Anlegen eines API-Schlüssels auswählbar, Liste zeigt, was ein Schlüssel darf
|
||||
- Vertrag: [`docs/erp-inventar-export.md`](docs/erp-inventar-export.md)
|
||||
- [x] Backend-Modul fertig, getestet, grün, committet
|
||||
- [x] Frontend-Views fertig, getestet, grün, committet
|
||||
- [ ] ERPNext-Importer (Repo ERPNext) nach dem Vertrag gebaut
|
||||
|
||||
## Prüfung September 2026
|
||||
|
||||
Prüfmatrix, Verifikationsstand und offene Entscheidungen: [`docs/pruefung-2026-09.md`](docs/pruefung-2026-09.md).
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user