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
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user