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

Datenquelle für den nächtlichen Inventar-Abgleich in ERPNext (ERPNext-
Ausbau T8, Entscheidungen E2/E3). Neues Recht erp-inventory:read, auch als
API-Schlüssel-Scope; es öffnet nur diese vier GET-Endpunkte:

- /api/v1/export/inventar/produkte: Artikel (ohne Beschreibung)
- .../chargen: Abfüllungen und Herstellungschargen mit Losnummer, MHD,
  Ausgangsmenge, Rest aus dem Bewegungsbuch, Füllmenge g und Sorte
- .../bewegungen?seit=&nach=&limit=: Shop-Verkauf, Retoure, Entnahmen mit
  Grund (HOFLADEN = Vertrauensschrank, EIGENBEDARF, BRUCH, PROBE,
  SONSTIGES) und Inventur; Cursor über (createdAt, id), Bewegungen erst
  nach 5 Minuten Wartezeit, damit der Cursor keine offene Buchung überholt
- .../ernten?jahr=: Nettogewicht kg je Kalenderjahr, wie die Statistik

Alle Listen stabil sortiert, seitenweise (1-500), Parameter mit
class-validator geprüft, Cursor je Endpunkt streng validiert. Feld-
Allowlists: keine Notizen, Benutzer, Standorte, Rohstoffe. Die Gründe gab
es im Datenmodell schon - keine Migration; das Recht legt der Katalog-
Sync beim Start an und gibt es der Rolle Administrator.

Vertrag mit Feldern, Einheiten, Fehlercodes und Beispielen:
docs/erp-inventar-export.md.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-12 11:43:57 +02:00
1 parent 1a1e70c94b
commit 68f81fabd4
17 files changed
+2569 -4

No files matched your search

@@ -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];
+2
View File
@@ -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),
}));
}
+664
View File
@@ -0,0 +1,664 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { encodeCursor } from '../src/erp-export/erp-cursor';
import { API_KEY_FAILURE_LIMIT } from '../src/api-keys/api-keys.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface CreatedKeyBody {
apiKey: { id: string };
token: string;
}
interface PageBody<T> {
contractVersion: number;
generatedAt: string;
items: T[];
nextCursor: string | null;
hasMore: boolean;
settledUntil?: string;
}
type Item = Record<string, unknown> & { id: string };
interface HarvestYearsBody {
contractVersion: number;
years: {
year: number;
netWeightKg: number;
harvestCount: number;
harvestsWithoutNetWeight: number;
}[];
}
const BASE = '/api/v1/export/inventar';
const ENDPOINTS = ['produkte', 'chargen', 'bewegungen', 'ernten'] as const;
/**
* Inventar-Spiegel für ERPNext (T8): nur lesend, nur mit erp-inventory:read,
* nur freigegebene Felder, stabil und seitenweise. Der ERPNext-Importer wird
* nach docs/erp-inventar-export.md gebaut und verlässt sich auf diese Form.
*/
describe('ERP-Inventar-Export (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const suffix = `${Date.now()}-${Math.round(Math.random() * 1e6)}`;
const password = 'SuperSecret123!';
let adminToken: string;
let readonlyToken: string;
let erpKey: string;
let shopKey: string;
let honeyProductId: string;
let candleProductId: string;
let bottlingId: string;
let productionBatchId: string;
// Laufeigener Zeitraum weit in der Vergangenheit: so finden sich die
// Bewegungen dieses Laufs zwischen denen anderer Läufe und Tests.
const movementBase = Date.UTC(2001, 0, 1) + (Date.now() % 1_000_000_000);
const at = (seconds: number) => new Date(movementBase + seconds * 1000);
const movementIds: Record<string, string> = {};
const server = () => app.getHttpServer();
// Jeder Test tritt als eigener Client auf (X-Forwarded-For, im Test wird
// ein Proxy-Hop vertraut): diese Datei stellt mehr Anfragen, als der
// globale Throttler einer einzelnen Adresse je Minute erlaubt.
let clientCounter = 0;
let clientIp = '198.51.100.1';
beforeEach(() => {
clientCounter += 1;
clientIp = `198.51.100.${clientCounter}`;
});
function get(path: string, token: string, ip = clientIp) {
return request(server())
.get(`${BASE}/${path}`)
.set('Authorization', `Bearer ${token}`)
.set('X-Forwarded-For', ip);
}
async function createKey(scopes: string[]): Promise<CreatedKeyBody> {
const response = await request(server())
.post('/api/v1/api-keys')
.set('Authorization', `Bearer ${adminToken}`)
.send({ name: `ERP-Test ${suffix}`, scopes })
.expect(201);
return response.body as CreatedKeyBody;
}
async function allItems(path: string): Promise<Item[]> {
const items: Item[] = [];
let nach: string | null = null;
for (let page = 0; page < 20; page++) {
const query = nach ? `?limit=500&nach=${nach}` : '?limit=500';
const body = (await get(`${path}${query}`, erpKey).expect(200))
.body as PageBody<Item>;
items.push(...body.items);
if (!body.hasMore) return items;
nach = body.nextCursor;
}
throw new Error('zu viele Seiten');
}
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const owner = await prisma.user.create({
data: {
email: `erp-admin-${suffix}@example.com`,
passwordHash,
name: 'Erika Imkerin',
role: { connect: { key: 'ADMIN' } },
},
});
await prisma.user.create({
data: {
email: `erp-ro-${suffix}@example.com`,
passwordHash,
name: 'Nur Lesen',
role: { connect: { key: 'READONLY' } },
},
});
adminToken = (await login(app, `erp-admin-${suffix}@example.com`, password))
.accessToken;
readonlyToken = (await login(app, `erp-ro-${suffix}@example.com`, password))
.accessToken;
erpKey = (await createKey(['erp-inventory:read'])).token;
shopKey = (await createKey(['shop-sync:read', 'shop-sync:write'])).token;
const apiary = await prisma.apiary.create({
data: { name: `Waldrand ${suffix}`, notes: 'Schlüssel unter dem Stein' },
});
const harvest = await prisma.harvest.create({
data: {
apiaryId: apiary.id,
userId: owner.id,
date: new Date('2026-07-14T00:00:00.000Z'),
honeyType: 'Sommertracht',
netWeight: 41,
notes: 'Volk 7 schwach',
},
});
const extraction = await prisma.extraction.create({
data: {
date: new Date('2026-07-20T00:00:00.000Z'),
userId: owner.id,
harvests: { create: [{ harvestId: harvest.id }] },
},
});
const honeyBatch = await prisma.honeyBatch.create({
data: {
batchNumber: `HB-${suffix}`,
extractionId: extraction.id,
honeyType: 'Sommertracht',
amount: 50,
},
});
honeyProductId = (
await prisma.product.create({
data: {
name: `Sommertracht 500 g ${suffix}`,
sku: `ERP-${suffix}`,
description: 'Interne Notiz: Lieferant Gläser Huber',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
},
})
).id;
candleProductId = (
await prisma.product.create({
data: {
name: `Bienenwachskerze ${suffix}`,
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
},
})
).id;
bottlingId = (
await prisma.bottlingBatch.create({
data: {
honeyBatchId: honeyBatch.id,
productId: honeyProductId,
userId: owner.id,
date: new Date('2026-08-01T00:00:00.000Z'),
bestBeforeDate: new Date('2028-08-01T00:00:00.000Z'),
jarSize: 500,
jarCount: 60,
totalAmount: 30,
lotNumber: `L-ERP-${suffix}`,
},
})
).id;
productionBatchId = (
await prisma.productionBatch.create({
data: {
productId: candleProductId,
userId: owner.id,
lotNumber: `K-ERP-${suffix}`,
date: new Date('2026-09-01T00:00:00.000Z'),
quantity: 20,
notes: 'Docht von Firma Meier',
},
})
).id;
const rawLot = await prisma.rawMaterialLot.create({
data: {
kind: 'BEESWAX',
lotNumber: `W-ERP-${suffix}`,
date: new Date('2026-07-20T00:00:00.000Z'),
quantity: 1000,
},
});
const book = async (
key: string,
data: Parameters<PrismaService['batchMovement']['create']>[0]['data'],
) => {
movementIds[key] = (await prisma.batchMovement.create({ data })).id;
};
const personal = {
notes: 'an Nachbar Müller verschenkt',
userId: owner.id,
};
await book('hofladen', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -6,
createdAt: at(1),
...personal,
});
await book('eigenbedarf', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'EIGENBEDARF',
quantity: -2,
createdAt: at(2),
...personal,
});
await book('verkauf', {
bottlingBatchId: bottlingId,
type: 'SHOP_SALE',
quantity: -3,
createdAt: at(2),
});
await book('bruch', {
productionBatchId,
type: 'WITHDRAWAL',
reason: 'BRUCH',
quantity: -1,
createdAt: at(3),
...personal,
});
await book('korrektur', {
bottlingBatchId: bottlingId,
type: 'CORRECTION',
quantity: 1,
createdAt: at(4),
...personal,
});
await book('rohstoff', {
rawMaterialLotId: rawLot.id,
type: 'WITHDRAWAL',
reason: 'SONSTIGES',
quantity: -10,
createdAt: at(5),
});
// Gerade eben gebucht: noch innerhalb der Wartezeit.
await book('frisch', {
bottlingBatchId: bottlingId,
type: 'WITHDRAWAL',
reason: 'PROBE',
quantity: -1,
});
});
afterAll(async () => {
await app.close();
});
describe('Zugang', () => {
it.each(ENDPOINTS)('%s ist mit dem ERP-Schlüssel lesbar', async (path) => {
await get(path, erpKey).expect(200);
});
it.each(ENDPOINTS)('%s verlangt einen Schlüssel (401)', async (path) => {
await request(server()).get(`${BASE}/${path}`).expect(401);
});
it('lehnt fehlerhafte und unbekannte Schlüssel ab (401)', async () => {
await get('produkte', 'ack_nope').expect(401);
await get('produkte', `ack_abcd1234_${'Z'.repeat(43)}`).expect(401);
});
it.each(ENDPOINTS)(
'%s bleibt dem Shop-Schlüssel verschlossen (403)',
async (path) => {
await get(path, shopKey).expect(403);
},
);
it('verweigert Benutzern ohne das Recht den Export (403)', async () => {
await get('produkte', readonlyToken).expect(403);
});
it('öffnet dem ERP-Schlüssel nichts außer dem Export', async () => {
const auth = { Authorization: `Bearer ${erpKey}` };
for (const path of [
'/api/v1/shop-sync',
'/api/v1/shop-sync/stock',
'/api/v1/products',
'/api/v1/harvests',
'/api/v1/bottling-batches',
'/api/v1/export/colonies.csv',
'/api/v1/users',
'/api/v1/auth/me',
]) {
await request(server()).get(path).set(auth).expect(403);
}
await request(server())
.post('/api/v1/shop-sync/movements')
.set(auth)
.send({ requests: [] })
.expect(403);
});
it('sperrt einen widerrufenen Schlüssel sofort (401)', async () => {
const created = await createKey(['erp-inventory:read']);
await get('produkte', created.token).expect(200);
await request(server())
.post(`/api/v1/api-keys/${created.apiKey.id}/revoke`)
.set('Authorization', `Bearer ${adminToken}`)
.expect(201);
await get('produkte', created.token).expect(401);
});
});
describe('Produkte', () => {
it('liefert nur die freigegebenen Felder, ohne Beschreibung', async () => {
const items = await allItems('produkte');
const honey = items.find((p) => p.id === honeyProductId);
const candle = items.find((p) => p.id === candleProductId);
expect(Object.keys(honey ?? {}).sort()).toEqual(
[
'id',
'kind',
'name',
'packagingSize',
'salesUnit',
'shopCategory',
'sku',
'status',
'updatedAt',
'weight',
].sort(),
);
expect(honey).toMatchObject({
name: `Sommertracht 500 g ${suffix}`,
sku: `ERP-${suffix}`,
kind: 'BOTTLED_HONEY',
shopCategory: 'HONIG',
status: 'ACTIVE',
packagingSize: 500,
weight: 500,
salesUnit: 'Glas',
});
expect(candle).toMatchObject({
kind: 'MANUFACTURED',
shopCategory: 'KERZEN',
});
expect(JSON.stringify(items)).not.toContain('Lieferant Gläser Huber');
});
it('blättert stabil nach id', async () => {
const first = (await get('produkte?limit=1', erpKey).expect(200))
.body as PageBody<Item>;
expect(first.contractVersion).toBe(1);
expect(first.items).toHaveLength(1);
expect(first.hasMore).toBe(true);
const second = (
await get(`produkte?limit=1&nach=${first.nextCursor}`, erpKey).expect(
200,
)
).body as PageBody<Item>;
expect(second.items).toHaveLength(1);
expect(second.items[0].id > first.items[0].id).toBe(true);
});
});
describe('Chargen', () => {
it('liefert Abfüllung und Herstellungscharge mit Rest aus dem Bewegungsbuch', async () => {
const items = await allItems('chargen');
const bottling = items.find((b) => b.id === bottlingId);
const production = items.find((b) => b.id === productionBatchId);
expect(bottling).toEqual({
id: bottlingId,
batchType: 'BOTTLING',
lotNumber: `L-ERP-${suffix}`,
productId: honeyProductId,
date: '2026-08-01T00:00:00.000Z',
bestBeforeDate: '2028-08-01T00:00:00.000Z',
initialQuantity: 60,
// 60 - 6 Hofladen - 2 Eigenbedarf - 3 Shop + 1 Inventur - 1 Probe
remainingQuantity: 49,
fillWeightGrams: 500,
honeyType: 'Sommertracht',
});
expect(production).toEqual({
id: productionBatchId,
batchType: 'PRODUCTION',
lotNumber: `K-ERP-${suffix}`,
productId: candleProductId,
date: '2026-09-01T00:00:00.000Z',
bestBeforeDate: null,
initialQuantity: 20,
remainingQuantity: 19,
fillWeightGrams: null,
honeyType: null,
});
// Abfüllungen vor Herstellungschargen.
const kinds = items.map((b) => b.batchType);
expect(kinds.lastIndexOf('BOTTLING')).toBeLessThan(
kinds.indexOf('PRODUCTION'),
);
const raw = JSON.stringify(items);
expect(raw).not.toContain('Docht von Firma Meier');
expect(raw).not.toContain('Erika Imkerin');
// Rohstoff-Chargen sind keine Ware.
expect(raw).not.toContain(`W-ERP-${suffix}`);
});
it('blättert ohne Lücken und Dubletten', async () => {
const all = (await allItems('chargen')).map((b) => b.id);
const limit = Math.max(1, Math.ceil(all.length / 3));
const seen: string[] = [];
let nach: string | null = null;
for (let page = 0; page < 20; page++) {
const query: string = nach
? `?limit=${limit}&nach=${nach}`
: `?limit=${limit}`;
const body = (await get(`chargen${query}`, erpKey).expect(200))
.body as PageBody<Item>;
expect(body.items.length).toBeLessThanOrEqual(limit);
seen.push(...body.items.map((b) => b.id));
nach = body.nextCursor;
if (!body.hasMore) break;
}
expect(new Set(seen).size).toBe(seen.length);
expect(seen).toEqual(
expect.arrayContaining([bottlingId, productionBatchId]),
);
// Andere Testdateien legen parallel Chargen an oder löschen welche:
// verglichen wird die Reihenfolge der Chargen, die beide Abrufe sahen.
const inBoth = new Set(all.filter((id) => seen.includes(id)));
expect(seen.filter((id) => inBoth.has(id))).toEqual(
all.filter((id) => inBoth.has(id)),
);
});
});
describe('Bewegungen', () => {
async function myMovements(): Promise<{ items: Item[]; cursor: string }> {
const mine = new Set(Object.values(movementIds));
const found: Item[] = [];
let query = `bewegungen?limit=2&seit=${at(0).toISOString()}`;
for (let page = 0; page < 40; page++) {
const body = (await get(query, erpKey).expect(200))
.body as PageBody<Item>;
found.push(...body.items.filter((m) => mine.has(m.id)));
const last = body.items[body.items.length - 1];
const passedMine =
last !== undefined &&
new Date(last.createdAt as string).getTime() > at(5).getTime();
if (!body.hasMore || passedMine) {
return { items: found, cursor: body.nextCursor! };
}
query = `bewegungen?limit=2&nach=${body.nextCursor}`;
}
throw new Error('zu viele Seiten');
}
it('liefert Entnahmen mit Grund, Shop-Verkäufe und Inventur in stabiler Reihenfolge', async () => {
const { items } = await myMovements();
// Zeitpunkt, dann id: eigenbedarf und verkauf haben denselben Zeitpunkt.
const sameTime = [movementIds.eigenbedarf, movementIds.verkauf].sort();
expect(items.map((m) => m.id)).toEqual([
movementIds.hofladen,
...sameTime,
movementIds.bruch,
movementIds.korrektur,
]);
expect(items.find((m) => m.id === movementIds.hofladen)).toEqual({
id: movementIds.hofladen,
createdAt: at(1).toISOString(),
batchType: 'BOTTLING',
batchId: bottlingId,
lotNumber: `L-ERP-${suffix}`,
productId: honeyProductId,
type: 'WITHDRAWAL',
reason: 'HOFLADEN',
quantity: -6,
});
expect(items.find((m) => m.id === movementIds.bruch)).toMatchObject({
batchType: 'PRODUCTION',
batchId: productionBatchId,
productId: candleProductId,
reason: 'BRUCH',
quantity: -1,
});
expect(items.find((m) => m.id === movementIds.verkauf)).toMatchObject({
type: 'SHOP_SALE',
reason: null,
quantity: -3,
});
expect(items.find((m) => m.id === movementIds.korrektur)).toMatchObject({
type: 'CORRECTION',
quantity: 1,
});
});
it('lässt Rohstoffe, Notizen, Benutzer und noch offene Buchungen weg', async () => {
const { items } = await myMovements();
const ids = items.map((m) => m.id);
expect(ids).not.toContain(movementIds.rohstoff);
expect(ids).not.toContain(movementIds.frisch);
const raw = JSON.stringify(items);
expect(raw).not.toContain('Müller');
expect(raw).not.toContain('Erika');
expect(raw).not.toContain('userId');
});
it('meldet, bis wann die Liste vollständig ist', async () => {
const before = Date.now();
const body = (await get('bewegungen?limit=1', erpKey).expect(200))
.body as PageBody<Item>;
const settled = new Date(body.settledUntil!).getTime();
expect(settled).toBeLessThanOrEqual(before - 4 * 60 * 1000);
expect(settled).toBeGreaterThan(before - 6 * 60 * 1000);
});
it('setzt mit dem letzten Cursor fort, ohne etwas doppelt zu liefern', async () => {
const { cursor } = await myMovements();
const body = (
await get(`bewegungen?limit=500&nach=${cursor}`, erpKey).expect(200)
).body as PageBody<Item>;
const mine = new Set(Object.values(movementIds));
// Nur Bewegungen hinter dem Cursor - die eigenen lagen davor.
expect(body.items.filter((m) => mine.has(m.id))).toEqual([]);
});
});
describe('Parameter', () => {
it.each([
['produkte?limit=0'],
['produkte?limit=501'],
['produkte?limit=zwei'],
['produkte?nach=kaputt!'],
['produkte?seite=2'],
['chargen?nach=bm90LWpzb24'],
['bewegungen?seit=gestern'],
['ernten?jahr=1800'],
['ernten?jahr=dieses'],
])('lehnt %s ab (400)', async (path) => {
await get(path, erpKey).expect(400);
});
it('lehnt einen Cursor eines anderen Endpunkts ab (400)', async () => {
const productCursor = encodeCursor({ k: 'produkte', id: honeyProductId });
await get(`bewegungen?nach=${productCursor}`, erpKey).expect(400);
});
it('lehnt seit und nach zusammen ab (400)', async () => {
const cursor = encodeCursor({
k: 'bewegungen',
ts: at(1).toISOString(),
id: movementIds.hofladen,
});
await get(
`bewegungen?seit=${at(0).toISOString()}&nach=${cursor}`,
erpKey,
).expect(400);
});
});
describe('Ernten', () => {
it('summiert das Nettogewicht eines Jahres und weist Ernten ohne Gewicht aus', async () => {
const year = 2003;
const read = async () =>
(
(await get(`ernten?jahr=${year}`, erpKey).expect(200))
.body as HarvestYearsBody
).years;
const before = await read();
expect(before).toHaveLength(1);
const owner = await prisma.user.findFirstOrThrow({
where: { email: `erp-admin-${suffix}@example.com` },
});
const apiary = await prisma.apiary.create({
data: { name: `Ernte-Stand ${suffix}` },
});
for (const [day, netWeight] of [
['2003-06-01', 12.5],
['2003-07-15', 7.25],
['2003-08-30', null],
['2004-01-01', 99],
] as const) {
await prisma.harvest.create({
data: {
apiaryId: apiary.id,
userId: owner.id,
date: new Date(`${day}T00:00:00.000Z`),
netWeight,
},
});
}
const [after] = await read();
expect(after.year).toBe(year);
expect(after.netWeightKg - before[0].netWeightKg).toBeCloseTo(19.75, 3);
expect(after.harvestCount - before[0].harvestCount).toBe(3);
expect(
after.harvestsWithoutNetWeight - before[0].harvestsWithoutNetWeight,
).toBe(1);
});
it('liefert ohne Jahr alle Jahre aufsteigend, ohne Standorte', async () => {
const body = (await get('ernten', erpKey).expect(200))
.body as HarvestYearsBody;
const years = body.years.map((y) => y.year);
expect(years).toEqual([...years].sort((a, b) => a - b));
expect(years).toContain(2026);
const raw = JSON.stringify(body);
expect(raw).not.toContain('Waldrand');
expect(raw).not.toContain('Volk 7');
});
});
describe('Fehlversuche', () => {
it('sperrt eine Adresse nach zu vielen falschen Schlüsseln mit 429, andere nicht', async () => {
const attacker = '203.0.113.50';
const wrong = `ack_abcd1234_${'Y'.repeat(43)}`;
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures; i++) {
await get('produkte', wrong, attacker).expect(401);
}
const blocked = await get('produkte', wrong, attacker).expect(429);
expect(blocked.body).toMatchObject({
statusCode: 429,
message: expect.stringContaining('Fehlversuche') as string,
});
// Auch ein richtiger Schlüssel verrät von dort nichts mehr.
await get('produkte', erpKey, attacker).expect(429);
await get('produkte', erpKey, '203.0.113.51').expect(200);
});
});
});