Merge feat/erp-inventar-export: read-only inventory mirror for ERPNext
Brings the ERPNext inventory export (T8) into main after its review: strict seit format, settle window on the DB clock, BatchMovement (createdAt, id) index, one scope per new API key and the failed-attempt lockout on the shared throttler storage. Conflicts resolved in the permission catalog (System and ERP-Anbindung groups) and ARCHITECTURE.md (lockout is Annahme 30). Gates on the merged state: backend lint, typecheck, 1909 unit, 471 e2e, build, no schema drift; frontend 929 tests, lint, typecheck, build. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
commit
2d5c740f7f
33 files changed
+3933
-106
No files matched your search
@@ -6,16 +6,37 @@ 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];
|
||||
|
||||
/**
|
||||
* Scopes that book (change data). Every other scope only reads. A new key
|
||||
* never mixes both kinds: a leaked read key cannot book, a leaked booking
|
||||
* key cannot read (ARCHITECTURE.md, Annahme 14).
|
||||
*/
|
||||
export const API_KEY_WRITE_SCOPES = [
|
||||
'shop-sync:write',
|
||||
] as const satisfies readonly ApiKeyScope[];
|
||||
|
||||
export function isApiKeyScope(value: string): value is ApiKeyScope {
|
||||
return (API_KEY_SCOPES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
function isWriteScope(scope: string): boolean {
|
||||
return (API_KEY_WRITE_SCOPES as readonly string[]).includes(scope);
|
||||
}
|
||||
|
||||
/** True if the scopes hold at least one reading and one booking right. */
|
||||
export function combinesReadAndWrite(scopes: readonly string[]): boolean {
|
||||
return scopes.some(isWriteScope) && scopes.some((s) => !isWriteScope(s));
|
||||
}
|
||||
@@ -1,12 +1,17 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { ThrottlerStorageModule } from '../common/throttler/throttler-storage.module';
|
||||
import { ApiKeysController } from './api-keys.controller';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import { FailedAttemptLimiter } from './failed-attempt-limiter';
|
||||
|
||||
// Global: the app-wide auth guards need ApiKeysService to recognise keys.
|
||||
// ThrottlerStorageModule: the failure counters share the rate-limit table,
|
||||
// so the limit holds across all api pods.
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [ThrottlerStorageModule],
|
||||
controllers: [ApiKeysController],
|
||||
providers: [ApiKeysService],
|
||||
providers: [ApiKeysService, FailedAttemptLimiter],
|
||||
exports: [ApiKeysService],
|
||||
})
|
||||
export class ApiKeysModule {}
|
||||
@@ -1,8 +1,265 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ApiKeysService } from './api-keys.service';
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||
import { ApiKeysService, extractApiKeyToken } from './api-keys.service';
|
||||
import {
|
||||
API_KEY_FAILURE_LIMIT,
|
||||
FailedAttemptLimiter,
|
||||
} from './failed-attempt-limiter';
|
||||
|
||||
// Synthetischer Schlüssel im echten Format - kein echter Zugang.
|
||||
const TOKEN = `ack_abcd1234_${'A'.repeat(43)}`;
|
||||
const OTHER_TOKEN = `ack_zzzz9999_${'B'.repeat(43)}`;
|
||||
|
||||
function storedKey(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
id: 'key-1',
|
||||
name: 'ERPNext',
|
||||
prefix: 'ack_abcd1234',
|
||||
keyHash: createHash('sha256').update(TOKEN).digest('hex'),
|
||||
scopes: ['erp-inventory:read'],
|
||||
createdByUserId: 'user-1',
|
||||
createdAt: new Date('2026-10-01T00:00:00.000Z'),
|
||||
lastUsedAt: null,
|
||||
lastUsedIp: null,
|
||||
allowedIps: [],
|
||||
expiresAt: null,
|
||||
revokedAt: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Stand-in for the shared counter table with the same counting rule as
|
||||
* PrismaThrottlerStorage within one window: count up to the limit, the hit
|
||||
* after it blocks, a blocked source keeps its count. Time and the database
|
||||
* itself are covered by the e2e tests.
|
||||
*/
|
||||
function inMemoryStorage(): PrismaThrottlerStorage {
|
||||
const rows = new Map<string, { hits: number; blocked: boolean }>();
|
||||
return {
|
||||
increment: (
|
||||
key: string,
|
||||
ttl: number,
|
||||
limit: number,
|
||||
blockDuration: number,
|
||||
throttlerName: string,
|
||||
) => {
|
||||
const id = `${throttlerName}|${key}`;
|
||||
const row = rows.get(id) ?? { hits: 0, blocked: false };
|
||||
const next = row.blocked
|
||||
? row
|
||||
: { hits: row.hits + 1, blocked: row.hits + 1 > limit };
|
||||
rows.set(id, next);
|
||||
return Promise.resolve({
|
||||
totalHits: next.hits,
|
||||
timeToExpire: ttl / 1000,
|
||||
isBlocked: next.blocked,
|
||||
timeToBlockExpire: next.blocked ? blockDuration / 1000 : 0,
|
||||
});
|
||||
},
|
||||
} as unknown as PrismaThrottlerStorage;
|
||||
}
|
||||
|
||||
describe('ApiKeysService.authenticate', () => {
|
||||
let prisma: { apiKey: { findUnique: jest.Mock; update: jest.Mock } };
|
||||
let service: ApiKeysService;
|
||||
let warn: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
prisma = {
|
||||
apiKey: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
};
|
||||
service = new ApiKeysService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record: jest.fn() } as unknown as AuditLogService,
|
||||
{ getPermissions: jest.fn() } as unknown as PermissionResolverService,
|
||||
new FailedAttemptLimiter(inMemoryStorage()),
|
||||
);
|
||||
warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore();
|
||||
});
|
||||
|
||||
function loggedText(): string {
|
||||
return JSON.stringify(warn.mock.calls);
|
||||
}
|
||||
|
||||
it('sucht den Schlüssel nur über seinen Hash', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||
|
||||
const principal = await service.authenticate(TOKEN, '203.0.113.7');
|
||||
|
||||
expect(principal).toEqual({ id: 'key-1', scopes: ['erp-inventory:read'] });
|
||||
expect(prisma.apiKey.findUnique).toHaveBeenCalledWith({
|
||||
where: { keyHash: createHash('sha256').update(TOKEN).digest('hex') },
|
||||
});
|
||||
});
|
||||
|
||||
it('lehnt einen fehlerhaft aufgebauten Schlüssel ohne Datenbankzugriff ab', async () => {
|
||||
await expect(service.authenticate('ack_kurz', '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lehnt einen unbekannten Schlüssel ab', async () => {
|
||||
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
it('lehnt einen widerrufenen (gesperrten) Schlüssel ab', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
||||
);
|
||||
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
expect(loggedText()).toContain('revoked');
|
||||
});
|
||||
|
||||
it('lehnt einen abgelaufenen Schlüssel ab', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ expiresAt: new Date('2020-01-01T00:00:00.000Z') }),
|
||||
);
|
||||
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
expect(loggedText()).toContain('expired');
|
||||
});
|
||||
|
||||
it('schreibt nie den Schlüssel selbst ins Log, nur das Präfix', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ revokedAt: new Date() }),
|
||||
);
|
||||
await service.authenticate(TOKEN, '203.0.113.7');
|
||||
await service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
||||
|
||||
const text = loggedText();
|
||||
expect(text).not.toContain(TOKEN);
|
||||
expect(text).not.toContain(OTHER_TOKEN);
|
||||
expect(text).not.toContain('A'.repeat(43));
|
||||
expect(text).not.toContain('B'.repeat(43));
|
||||
expect(text).toContain('ack_abcd1234');
|
||||
});
|
||||
|
||||
it('gibt nur Rechte weiter, die für Schlüssel freigegeben sind', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ scopes: ['erp-inventory:read', 'users:create'] }),
|
||||
);
|
||||
const principal = await service.authenticate(TOKEN, '203.0.113.7');
|
||||
expect(principal?.scopes).toEqual(['erp-inventory:read']);
|
||||
});
|
||||
|
||||
it('weist einen richtigen Schlüssel von einer nicht freigegebenen Adresse mit 403 ab', async () => {
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ allowedIps: ['198.51.100.0/24'] }),
|
||||
);
|
||||
await expect(
|
||||
service.authenticate(TOKEN, '203.0.113.7'),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
});
|
||||
|
||||
async function failFrom(ip: string, times: number): Promise<void> {
|
||||
for (let i = 0; i < times; i++) {
|
||||
// Unbekannter, aber richtig aufgebauter Schlüssel.
|
||||
await expect(service.authenticate(OTHER_TOKEN, ip)).resolves.toBe(null);
|
||||
}
|
||||
}
|
||||
|
||||
it(`sperrt unbekannte Schlüssel einer Adresse nach ${API_KEY_FAILURE_LIMIT.maxFailures} Fehlversuchen mit 429`, async () => {
|
||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||
|
||||
const blocked = service.authenticate(OTHER_TOKEN, '203.0.113.7');
|
||||
await expect(blocked).rejects.toBeInstanceOf(HttpException);
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
expect(loggedText()).toContain('203.0.113.7');
|
||||
|
||||
// Widerrufene Schlüssel sind ebenfalls ungültig und bleiben gesperrt.
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(
|
||||
storedKey({ revokedAt: new Date('2026-10-02T00:00:00.000Z') }),
|
||||
);
|
||||
await expect(
|
||||
service.authenticate(TOKEN, '203.0.113.7'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
|
||||
// Andere Adressen sind nicht betroffen.
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '198.51.100.1'),
|
||||
).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it('meldet das Erreichen der Grenze einmal im Log', async () => {
|
||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
|
||||
const limitMessages = warn.mock.calls.filter(([message]) =>
|
||||
String(message).includes('get 429'),
|
||||
);
|
||||
expect(limitMessages).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('weist einen gültigen Schlüssel nie wegen der Sperre ab - sonst könnte jeder ERPNext oder den Shop aussperren', async () => {
|
||||
await failFrom('203.0.113.7', API_KEY_FAILURE_LIMIT.maxFailures);
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
prisma.apiKey.findUnique.mockResolvedValueOnce(storedKey());
|
||||
|
||||
await expect(service.authenticate(TOKEN, '203.0.113.7')).resolves.toEqual({
|
||||
id: 'key-1',
|
||||
scopes: ['erp-inventory:read'],
|
||||
});
|
||||
});
|
||||
|
||||
it('zählt fehlerhaft aufgebaute Schlüssel nicht als Fehlversuch', async () => {
|
||||
for (let i = 0; i < API_KEY_FAILURE_LIMIT.maxFailures * 2; i++) {
|
||||
await expect(service.authenticate('ack_x', '203.0.113.7')).resolves.toBe(
|
||||
null,
|
||||
);
|
||||
}
|
||||
// Weder gesperrt noch eine Datenbankabfrage.
|
||||
expect(prisma.apiKey.findUnique).not.toHaveBeenCalled();
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '203.0.113.7'),
|
||||
).resolves.toBe(null);
|
||||
});
|
||||
|
||||
it('zählt IPv6-Adressen aus demselben /64 gemeinsam (wie der Throttler)', async () => {
|
||||
// Wechselnde Adressen im eigenen /64 umgehen die Sperre nicht.
|
||||
for (let i = 1; i <= API_KEY_FAILURE_LIMIT.maxFailures; i++) {
|
||||
await service.authenticate(
|
||||
OTHER_TOKEN,
|
||||
`2001:db8:1:2::${i.toString(16)}`,
|
||||
);
|
||||
}
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '2001:db8:1:2::ffff'),
|
||||
).rejects.toMatchObject({ status: 429 });
|
||||
// Ein anderes /64 ist frei.
|
||||
await expect(
|
||||
service.authenticate(OTHER_TOKEN, '2001:db8:1:3::1'),
|
||||
).resolves.toBe(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ApiKeysService.create', () => {
|
||||
let service: ApiKeysService;
|
||||
@@ -19,12 +276,15 @@ describe('ApiKeysService.create', () => {
|
||||
const permissionResolver = {
|
||||
getPermissions: jest
|
||||
.fn()
|
||||
.mockResolvedValue(new Set(['shop-sync:read', 'shop-sync:write'])),
|
||||
.mockResolvedValue(
|
||||
new Set(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read']),
|
||||
),
|
||||
};
|
||||
service = new ApiKeysService(
|
||||
prisma as unknown as PrismaService,
|
||||
{ record: jest.fn() } as unknown as AuditLogService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
new FailedAttemptLimiter(inMemoryStorage()),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -45,7 +305,46 @@ describe('ApiKeysService.create', () => {
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(['shop-sync:read', 'shop-sync:write'])(
|
||||
it('refuses the read-only ERP scope together with booking', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
name: 'ERPNext',
|
||||
scopes: ['erp-inventory:read', 'shop-sync:write'],
|
||||
},
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||
),
|
||||
);
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a new key with more than one scope, even if all of them read', async () => {
|
||||
await expect(
|
||||
service.create(
|
||||
{ name: 'Alles', scopes: ['shop-sync:read', 'erp-inventory:read'] },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toThrow(
|
||||
new BadRequestException(
|
||||
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||
),
|
||||
);
|
||||
expect(prisma.apiKey.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('treats the same scope given twice as one', async () => {
|
||||
const created = await service.create(
|
||||
{ name: 'ERPNext', scopes: ['erp-inventory:read', 'erp-inventory:read'] },
|
||||
'user-1',
|
||||
);
|
||||
expect(created.apiKey.scopes).toEqual(['erp-inventory:read']);
|
||||
});
|
||||
|
||||
it.each(['shop-sync:read', 'shop-sync:write', 'erp-inventory:read'])(
|
||||
'still creates a key with only %s',
|
||||
async (scope) => {
|
||||
const created = await service.create(
|
||||
@@ -56,3 +355,11 @@ describe('ApiKeysService.create', () => {
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe('extractApiKeyToken', () => {
|
||||
it('erkennt nur Bearer-Schlüssel im ApiCore-Format', () => {
|
||||
expect(extractApiKeyToken(`Bearer ${TOKEN}`)).toBe(TOKEN);
|
||||
expect(extractApiKeyToken('Bearer eyJhbGciOi.jwt')).toBeNull();
|
||||
expect(extractApiKeyToken(undefined)).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
@@ -10,10 +12,19 @@ import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { ApiKey } from '../../generated/prisma';
|
||||
import { CreateApiKeyDto } from './dto/create-api-key.dto';
|
||||
import { ApiKeyScope, isApiKeyScope } from './api-key-scopes';
|
||||
import {
|
||||
ApiKeyScope,
|
||||
combinesReadAndWrite,
|
||||
isApiKeyScope,
|
||||
} from './api-key-scopes';
|
||||
import { UpdateApiKeyDto } from './dto/update-api-key.dto';
|
||||
import { isIpAllowed, normalizeAllowlistEntry } from './ip-allowlist';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
import {
|
||||
API_KEY_FAILURE_LIMIT,
|
||||
FailedAttemptLimiter,
|
||||
} from './failed-attempt-limiter';
|
||||
import { rateLimitSubject } from '../common/throttler/rate-limit-subject';
|
||||
|
||||
function normalizeAllowlist(entries: readonly string[] | undefined): string[] {
|
||||
return [
|
||||
@@ -31,6 +42,11 @@ const PREFIX_ALPHABET = 'abcdefghijklmnopqrstuvwxyz0123456789';
|
||||
// lastUsedAt is informational; writing it on every request would turn each
|
||||
// read into a write for no benefit.
|
||||
const LAST_USED_RESOLUTION_MS = 5 * 60 * 1000;
|
||||
// "ack_" plus the 8-character prefix: identifies a key in logs without
|
||||
// revealing its secret part.
|
||||
const LOGGABLE_PREFIX_LENGTH = 12;
|
||||
|
||||
type RejectReason = 'unknown' | 'revoked' | 'expired';
|
||||
|
||||
/** What a request authenticated by an API key may do - it is not a user. */
|
||||
export interface ApiKeyPrincipal {
|
||||
@@ -73,6 +89,8 @@ export class ApiKeysService {
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly auditLog: AuditLogService,
|
||||
private readonly permissionResolver: PermissionResolverService,
|
||||
// Counters shared by all api pods (ThrottlerHit), not process memory.
|
||||
private readonly failures: FailedAttemptLimiter,
|
||||
) {}
|
||||
|
||||
async findAll(): Promise<ApiKeySummary[]> {
|
||||
@@ -86,16 +104,19 @@ export class ApiKeysService {
|
||||
dto: CreateApiKeyDto,
|
||||
actingUserId: string,
|
||||
): Promise<CreatedApiKey> {
|
||||
// Reading and booking are separate keys: a leaked read key cannot book,
|
||||
// a leaked booking key cannot read. Existing combined keys stay valid.
|
||||
if (
|
||||
dto.scopes.includes('shop-sync:read') &&
|
||||
dto.scopes.includes('shop-sync:write')
|
||||
) {
|
||||
// One scope per new key: a leaked read key cannot book, a leaked booking
|
||||
// key cannot read, and each connected system gets its own key.
|
||||
// Existing multi-scope keys stay valid (ARCHITECTURE.md, Annahme 14).
|
||||
if (combinesReadAndWrite(dto.scopes)) {
|
||||
throw new BadRequestException(
|
||||
'Lese- und Buchungsrecht bitte als getrennte Schlüssel anlegen',
|
||||
);
|
||||
}
|
||||
if (new Set(dto.scopes).size > 1) {
|
||||
throw new BadRequestException(
|
||||
'Je Schlüssel nur eine Berechtigung - bitte getrennte Schlüssel anlegen',
|
||||
);
|
||||
}
|
||||
// Nobody hands a machine more than they may do themselves.
|
||||
const callerPermissions =
|
||||
await this.permissionResolver.getPermissions(actingUserId);
|
||||
@@ -187,6 +208,13 @@ export class ApiKeysService {
|
||||
/**
|
||||
* Resolves a presented key to what it may do, or null for anything that
|
||||
* must not pass: malformed, unknown, revoked or expired.
|
||||
*
|
||||
* The key is found by the SHA-256 of the presented token through a unique
|
||||
* index - never by comparing secrets byte by byte, so response timing says
|
||||
* nothing about the stored key. Order matters: the lookup comes first, and
|
||||
* only an invalid key counts against (or is stopped by) the failure limit
|
||||
* of the client network. A malformed token is not a guess at a key and is
|
||||
* refused without lookup and without counting.
|
||||
*/
|
||||
async authenticate(
|
||||
token: string,
|
||||
@@ -199,13 +227,53 @@ export class ApiKeysService {
|
||||
where: { keyHash: hashToken(token) },
|
||||
});
|
||||
const now = new Date();
|
||||
if (
|
||||
!apiKey ||
|
||||
apiKey.revokedAt ||
|
||||
(apiKey.expiresAt && apiKey.expiresAt <= now)
|
||||
) {
|
||||
return null;
|
||||
const reason = rejectReason(apiKey, now);
|
||||
if (reason === null && apiKey) {
|
||||
return this.accept(apiKey, clientIp, now);
|
||||
}
|
||||
await this.recordRejection(token, reason ?? 'unknown', clientIp);
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Counts an invalid key; 429 once the client network is over the limit. */
|
||||
private async recordRejection(
|
||||
token: string,
|
||||
reason: RejectReason,
|
||||
clientIp: string | undefined,
|
||||
): Promise<void> {
|
||||
// Counted per client network like the throttler (IPv6 /64), so rotating
|
||||
// addresses inside one's own prefix does not reset the budget.
|
||||
const subject = clientIp ? rateLimitSubject(clientIp) : 'unknown';
|
||||
const verdict = await this.failures.recordFailure(subject);
|
||||
if (verdict.blocked) {
|
||||
// Same body shape as the built-in 4xx exceptions (JSON object, not a
|
||||
// bare string), so clients can read `message` uniformly.
|
||||
throw new HttpException(
|
||||
{
|
||||
statusCode: HttpStatus.TOO_MANY_REQUESTS,
|
||||
error: 'Too Many Requests',
|
||||
message:
|
||||
'Zu viele Fehlversuche mit API-Schlüsseln - bitte später erneut versuchen',
|
||||
},
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
// Only the public prefix is ever logged, never the key itself.
|
||||
this.logger.warn(
|
||||
`API key rejected (${reason}): ${token.slice(0, LOGGABLE_PREFIX_LENGTH)} from ${clientIp ?? 'unknown'}`,
|
||||
);
|
||||
if (verdict.limitReached) {
|
||||
this.logger.warn(
|
||||
`Invalid API keys from ${subject} reached ${API_KEY_FAILURE_LIMIT.maxFailures} failures - further invalid keys get 429 for ${API_KEY_FAILURE_LIMIT.windowMs / 60_000} minutes`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async accept(
|
||||
apiKey: ApiKey,
|
||||
clientIp: string | undefined,
|
||||
now: Date,
|
||||
): Promise<ApiKeyPrincipal> {
|
||||
if (!isIpAllowed(clientIp, apiKey.allowedIps)) {
|
||||
// The key itself is right: say so, so the admin finds the cause - a
|
||||
// thief learns nothing new, they already hold the key.
|
||||
@@ -232,6 +300,13 @@ export class ApiKeysService {
|
||||
}
|
||||
}
|
||||
|
||||
function rejectReason(apiKey: ApiKey | null, now: Date): RejectReason | null {
|
||||
if (!apiKey) return 'unknown';
|
||||
if (apiKey.revokedAt) return 'revoked';
|
||||
if (apiKey.expiresAt && apiKey.expiresAt <= now) return 'expired';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** The key from an `Authorization: Bearer ack_…` header, or null. */
|
||||
export function extractApiKeyToken(
|
||||
authorization: string | undefined,
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||
import {
|
||||
API_KEY_FAILURE_LIMIT,
|
||||
API_KEY_FAILURE_THROTTLER,
|
||||
FailedAttemptLimiter,
|
||||
} from './failed-attempt-limiter';
|
||||
|
||||
describe('FailedAttemptLimiter', () => {
|
||||
const increment = jest.fn();
|
||||
const limiter = new FailedAttemptLimiter({
|
||||
increment,
|
||||
} as unknown as PrismaThrottlerStorage);
|
||||
|
||||
function record(totalHits: number, isBlocked: boolean) {
|
||||
return {
|
||||
totalHits,
|
||||
timeToExpire: 600,
|
||||
isBlocked,
|
||||
timeToBlockExpire: isBlocked ? 900 : 0,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
increment.mockReset();
|
||||
increment.mockResolvedValue(record(1, false));
|
||||
});
|
||||
|
||||
it('zählt in der gemeinsamen Zähler-Tabelle, getrennt vom Anfrage-Limit', async () => {
|
||||
await limiter.recordFailure('203.0.113.7');
|
||||
|
||||
// Ein Aufruf = eine Upsert-Anweisung mit DB-Uhr (PrismaThrottlerStorage),
|
||||
// unter eigenem Throttler-Namen und eigenem Schlüssel-Präfix.
|
||||
expect(increment).toHaveBeenCalledTimes(1);
|
||||
expect(increment).toHaveBeenCalledWith(
|
||||
`${API_KEY_FAILURE_THROTTLER}:203.0.113.7`,
|
||||
API_KEY_FAILURE_LIMIT.windowMs,
|
||||
API_KEY_FAILURE_LIMIT.maxFailures,
|
||||
API_KEY_FAILURE_LIMIT.windowMs,
|
||||
API_KEY_FAILURE_THROTTLER,
|
||||
);
|
||||
expect(API_KEY_FAILURE_THROTTLER).toBe('api-key-failures');
|
||||
});
|
||||
|
||||
it('behält die vereinbarten Grenzen: 20 Fehlversuche in 15 Minuten', () => {
|
||||
expect(API_KEY_FAILURE_LIMIT).toEqual({
|
||||
maxFailures: 20,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
});
|
||||
});
|
||||
|
||||
it('sperrt nicht unterhalb der Grenze', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures - 1, false),
|
||||
);
|
||||
|
||||
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||
blocked: false,
|
||||
limitReached: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('meldet genau den Fehlversuch, mit dem die Grenze erreicht ist', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures, false),
|
||||
);
|
||||
|
||||
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||
blocked: false,
|
||||
limitReached: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('sperrt, sobald die Datenbank die Quelle als gesperrt meldet', async () => {
|
||||
increment.mockResolvedValueOnce(
|
||||
record(API_KEY_FAILURE_LIMIT.maxFailures + 1, true),
|
||||
);
|
||||
|
||||
await expect(limiter.recordFailure('203.0.113.7')).resolves.toEqual({
|
||||
blocked: true,
|
||||
limitReached: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('gibt Datenbankfehler weiter, statt still durchzulassen', async () => {
|
||||
increment.mockRejectedValueOnce(new Error('db down'));
|
||||
|
||||
await expect(limiter.recordFailure('203.0.113.7')).rejects.toThrow(
|
||||
'db down',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { PrismaThrottlerStorage } from '../common/throttler/prisma-throttler.storage';
|
||||
|
||||
/**
|
||||
* Unknown, revoked or expired keys per client network before that network
|
||||
* gets 429 for further invalid keys - the same budget the ERP export
|
||||
* contract names (docs/erp-inventar-export.md). It slows down guessing and a
|
||||
* misconfigured client; a valid key is never refused by it. Otherwise
|
||||
* anyone able to send requests from (or forge) a shared address - the
|
||||
* cluster egress, a proxy - could lock ERPNext or the shop out, and a key
|
||||
* holds 256 random bits, so letting it through costs nothing.
|
||||
*/
|
||||
export const API_KEY_FAILURE_LIMIT = {
|
||||
maxFailures: 20,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Throttler name and key prefix of these counters in "ThrottlerHit", so
|
||||
* they never mix with the request rate limits stored in the same table.
|
||||
*/
|
||||
export const API_KEY_FAILURE_THROTTLER = 'api-key-failures';
|
||||
|
||||
export interface FailedAttemptVerdict {
|
||||
/** The source is over the limit: refuse the request with 429. */
|
||||
blocked: boolean;
|
||||
/** This failure is the one that reached the limit (once per window). */
|
||||
limitReached: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts invalid API keys per source (client network) in the shared,
|
||||
* database-backed rate-limit storage, so the limit holds across all api
|
||||
* pods. One upsert per failure, every time comparison on the database
|
||||
* clock (PrismaThrottlerStorage). The window starts with the first failure;
|
||||
* the failure after the limit blocks the source for one window length.
|
||||
* A database error propagates: the request fails instead of passing.
|
||||
*/
|
||||
@Injectable()
|
||||
export class FailedAttemptLimiter {
|
||||
constructor(private readonly storage: PrismaThrottlerStorage) {}
|
||||
|
||||
async recordFailure(source: string): Promise<FailedAttemptVerdict> {
|
||||
const { maxFailures, windowMs } = API_KEY_FAILURE_LIMIT;
|
||||
const record = await this.storage.increment(
|
||||
`${API_KEY_FAILURE_THROTTLER}:${source}`,
|
||||
windowMs,
|
||||
maxFailures,
|
||||
windowMs,
|
||||
API_KEY_FAILURE_THROTTLER,
|
||||
);
|
||||
return {
|
||||
blocked: record.isBlocked,
|
||||
limitReached: !record.isBlocked && record.totalHits === maxFailures,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -65,3 +65,56 @@ describe('configureApp trust proxy logging', () => {
|
||||
log.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('configureApp trust proxy warning', () => {
|
||||
const originalProxy = process.env.TRUST_PROXY;
|
||||
const originalEnv = process.env.NODE_ENV;
|
||||
afterEach(() => {
|
||||
if (originalProxy === undefined) delete process.env.TRUST_PROXY;
|
||||
else process.env.TRUST_PROXY = originalProxy;
|
||||
if (originalEnv === undefined) delete process.env.NODE_ENV;
|
||||
else process.env.NODE_ENV = originalEnv;
|
||||
});
|
||||
|
||||
function configure(): jest.SpyInstance {
|
||||
const warn = jest.spyOn(Logger.prototype, 'warn').mockImplementation();
|
||||
jest.spyOn(Logger.prototype, 'log').mockImplementation();
|
||||
const app = {
|
||||
set: jest.fn(),
|
||||
useBodyParser: jest.fn(),
|
||||
use: jest.fn(),
|
||||
useGlobalFilters: jest.fn(),
|
||||
enableCors: jest.fn(),
|
||||
setGlobalPrefix: jest.fn(),
|
||||
enableVersioning: jest.fn(),
|
||||
useGlobalPipes: jest.fn(),
|
||||
};
|
||||
configureApp(app as unknown as NestExpressApplication);
|
||||
return warn;
|
||||
}
|
||||
|
||||
function trustProxyWarnings(warn: jest.SpyInstance): unknown[][] {
|
||||
const calls = warn.mock.calls as unknown[][];
|
||||
return calls.filter(([m]) => String(m).includes('TRUST_PROXY'));
|
||||
}
|
||||
|
||||
afterEach(() => jest.restoreAllMocks());
|
||||
|
||||
it('warnt in Produktion, wenn TRUST_PROXY fehlt', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.TRUST_PROXY;
|
||||
expect(trustProxyWarnings(configure())).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('warnt nicht, wenn TRUST_PROXY gesetzt ist', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.TRUST_PROXY = 'loopback, 10.42.0.0/16';
|
||||
expect(trustProxyWarnings(configure())).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('warnt nicht in der Entwicklung', () => {
|
||||
process.env.NODE_ENV = 'development';
|
||||
delete process.env.TRUST_PROXY;
|
||||
expect(trustProxyWarnings(configure())).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -50,9 +50,17 @@ export function configureApp(app: NestExpressApplication): void {
|
||||
// to work correctly rather than rate-limiting all traffic together.
|
||||
const trustProxy = trustProxySetting();
|
||||
app.set('trust proxy', trustProxy);
|
||||
new Logger('Bootstrap').log(
|
||||
describeTrustProxy(trustProxy, !!process.env.TRUST_PROXY?.trim()),
|
||||
);
|
||||
const bootstrapLogger = new Logger('Bootstrap');
|
||||
const trustProxyExplicit = !!process.env.TRUST_PROXY?.trim();
|
||||
bootstrapLogger.log(describeTrustProxy(trustProxy, trustProxyExplicit));
|
||||
if (!trustProxyExplicit && process.env.NODE_ENV === 'production') {
|
||||
// Behind Cloudflare and Traefik one hop is wrong: req.ip becomes the
|
||||
// proxy, so rate limits, API-key failure limits and IP allowlists act
|
||||
// on a shared address instead of the client.
|
||||
bootstrapLogger.warn(
|
||||
'TRUST_PROXY is not set in production - client IPs for rate limits and API-key allowlists are likely the proxy address; set it as in deploy/k8s/base/backend/configmap.example.yaml',
|
||||
);
|
||||
}
|
||||
// No ETags: they let caches and proxies revalidate (and keep) personal
|
||||
// responses that are meant to be no-store.
|
||||
app.set('etag', false);
|
||||
|
||||
@@ -51,6 +51,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 { SystemModule } from './system/system.module';
|
||||
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
|
||||
@@ -116,6 +117,7 @@ import { IdempotencyInterceptor } from './common/idempotency/idempotency.interce
|
||||
SiteSettingsModule,
|
||||
BackupModule,
|
||||
ShopSyncModule,
|
||||
ErpExportModule,
|
||||
ApiKeysModule,
|
||||
SystemModule,
|
||||
],
|
||||
|
||||
@@ -370,6 +370,15 @@ export const PERMISSION_CATALOG = [
|
||||
],
|
||||
3500,
|
||||
),
|
||||
// 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']],
|
||||
3600,
|
||||
),
|
||||
] as const satisfies readonly PermissionCatalogEntry[];
|
||||
|
||||
export type PermissionKey = (typeof PERMISSION_CATALOG)[number]['key'];
|
||||
@@ -0,0 +1,110 @@
|
||||
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.each([
|
||||
'2026-01-01',
|
||||
'2026-01-01T00:00Z',
|
||||
'2026-01-01T00:00:00Z',
|
||||
'2026-01-01T00:00:00.123Z',
|
||||
'2026-01-01T01:00:00+01:00',
|
||||
'2025-12-31T22:30:00.5-02:30',
|
||||
'2000-01-01',
|
||||
'2100-12-31T23:59:59Z',
|
||||
])('nimmt seit=%s an', async (seit) => {
|
||||
expect(await errorsOf(ErpMovementsQueryDto, { seit })).toEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'gestern',
|
||||
'2026-13-01',
|
||||
'01.01.2026',
|
||||
// Andere ISO-8601-Formen: führten zu 500 oder wurden falsch gelesen.
|
||||
'2026-W01',
|
||||
'20260101',
|
||||
'-2026-01-01',
|
||||
'+002026-01-01',
|
||||
'2026-001',
|
||||
// Uhrzeit ohne Zone wäre Ortszeit des Servers.
|
||||
'2026-01-01T00:00',
|
||||
'2026-01-01T24:00',
|
||||
'2026-01-01T24:00Z',
|
||||
'2026-01-01T23:60Z',
|
||||
'2026-01-01T23:59:60Z',
|
||||
'2026-01-01T00:00:00+24:00',
|
||||
'2026-01-01 00:00:00Z',
|
||||
'2026-02-30',
|
||||
// Gleiche Jahresgrenzen wie der Cursor: sonst 500 in der Datenbank.
|
||||
'1999-12-31',
|
||||
'2101-01-01',
|
||||
'2100-12-31T23:00:00-02:00',
|
||||
'9999-12-31T00:00:00Z',
|
||||
])('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,86 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateBy,
|
||||
} from 'class-validator';
|
||||
import {
|
||||
ERP_MAX_YEAR,
|
||||
ERP_MIN_YEAR,
|
||||
parseSeit,
|
||||
SEIT_PATTERN,
|
||||
} from '../erp-time';
|
||||
|
||||
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 (einschließlich): JJJJ-MM-TT (Mitternacht UTC) oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm, Jahr ${ERP_MIN_YEAR}–${ERP_MAX_YEAR}. Für den ersten Abruf; danach mit "nach" weiterlesen. Nicht zusammen mit "nach".`,
|
||||
example: '2026-01-01T00:00:00Z',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(SEIT_PATTERN, {
|
||||
message:
|
||||
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
|
||||
})
|
||||
@ValidateBy({
|
||||
name: 'isErpSeit',
|
||||
validator: {
|
||||
validate: (value: unknown) =>
|
||||
typeof value === 'string' && parseSeit(value) !== null,
|
||||
defaultMessage: () =>
|
||||
`seit muss ein gültiger Zeitpunkt in den Jahren ${ERP_MIN_YEAR} bis ${ERP_MAX_YEAR} sein`,
|
||||
},
|
||||
})
|
||||
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,93 @@
|
||||
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' }),
|
||||
],
|
||||
[
|
||||
'Jahr jenseits des Datenbankbereichs',
|
||||
'bewegungen',
|
||||
raw({ k: 'bewegungen', ts: '+275760-09-13T00:00:00.000Z', id: 'c1' }),
|
||||
],
|
||||
[
|
||||
'Jahr vor 2000',
|
||||
'bewegungen',
|
||||
raw({ k: 'bewegungen', ts: '1999-12-31T23:59:59.999Z', id: 'c1' }),
|
||||
],
|
||||
[
|
||||
'Jahr nach 2100',
|
||||
'bewegungen',
|
||||
raw({ k: 'bewegungen', ts: '2101-01-01T00:00:00.000Z', id: 'c1' }),
|
||||
],
|
||||
] as const)('lehnt ab: %s', (_label, kind, cursor) => {
|
||||
expect(() => decodeCursor(cursor, kind)).toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it.each(['2000-01-01T00:00:00.000Z', '2100-12-31T23:59:59.999Z'])(
|
||||
'nimmt den Rand des erlaubten Bereichs an: %s',
|
||||
(ts) => {
|
||||
const cursor = encodeCursor({ k: 'bewegungen', ts, id: 'c1' });
|
||||
expect(decodeCursor(cursor, 'bewegungen').ts).toBe(ts);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { isPlausibleErpDate } from './erp-time';
|
||||
|
||||
/**
|
||||
* 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;
|
||||
// Nur die eigene Form (toISOString) und nur plausible Jahre (erp-time.ts).
|
||||
const date = new Date(value);
|
||||
return isPlausibleErpDate(date) && 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,572 @@
|
||||
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');
|
||||
// Die Datenbank-Uhr geht hier bewusst anders als die Uhr des Pods (NOW):
|
||||
// die Wartezeit muss von der Datenbank kommen.
|
||||
const DB_SETTLED_UNTIL = new Date('2026-10-12T01:52:30.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 };
|
||||
$queryRaw: 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([]) },
|
||||
$queryRaw: jest
|
||||
.fn()
|
||||
.mockResolvedValue([{ settledUntil: DB_SETTLED_UNTIL }]),
|
||||
};
|
||||
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 = DB_SETTLED_UNTIL;
|
||||
|
||||
it('nimmt die Wartezeit von der Datenbank-Uhr, nicht von der Uhr des Pods', async () => {
|
||||
const page = await service.movements({});
|
||||
|
||||
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
||||
const [strings, ...values] = prisma.$queryRaw.mock.calls[0] as [
|
||||
readonly string[],
|
||||
...unknown[],
|
||||
];
|
||||
expect(strings.join('?')).toContain('now()');
|
||||
expect(values).toEqual([MOVEMENT_SETTLE_MS]);
|
||||
expect(values.some((value) => value instanceof Date)).toBe(false);
|
||||
expect(page.settledUntil).toEqual(DB_SETTLED_UNTIL);
|
||||
expect(page.settledUntil).not.toEqual(
|
||||
new Date(NOW.getTime() - MOVEMENT_SETTLE_MS),
|
||||
);
|
||||
const where = lastArgs(prisma.batchMovement.findMany).where as {
|
||||
AND: unknown[];
|
||||
};
|
||||
expect(where.AND[1]).toEqual({ createdAt: { lte: DB_SETTLED_UNTIL } });
|
||||
});
|
||||
|
||||
it('liest "seit" mit Zone als UTC-Zeitpunkt', async () => {
|
||||
await service.movements({ seit: '2026-01-01T01:00:00+01:00' });
|
||||
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('lehnt ein "seit" außerhalb der Vertragsformen ab, ohne die Datenbank zu fragen', async () => {
|
||||
await expect(
|
||||
service.movements({ seit: '2026-W01' }),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.batchMovement.findMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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,446 @@
|
||||
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';
|
||||
import { parseSeit } from './erp-time';
|
||||
|
||||
/**
|
||||
* 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. Die Grenze rechnet
|
||||
* die Datenbank-Uhr (now()), nicht die Uhr des Pods: mehrere api-Pods geben
|
||||
* so dieselbe Grenze, auch wenn ihre Uhren auseinanderlaufen.
|
||||
*/
|
||||
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 since = query.seit !== undefined ? seitDate(query.seit) : undefined;
|
||||
const settledUntil = await this.settledUntil();
|
||||
|
||||
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,
|
||||
...(since !== undefined ? { gte: since } : {}),
|
||||
},
|
||||
},
|
||||
...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),
|
||||
};
|
||||
}
|
||||
|
||||
/** Bis hierhin ist das Bewegungsbuch vollständig: DB-Uhr minus Wartezeit. */
|
||||
private async settledUntil(): Promise<Date> {
|
||||
const [row] = await this.prisma.$queryRaw<{ settledUntil: Date }[]>`
|
||||
SELECT now() - (${MOVEMENT_SETTLE_MS}::double precision * interval '1 millisecond') AS "settledUntil"
|
||||
`;
|
||||
return row.settledUntil;
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** "seit" ist im DTO schon geprüft; hier nur noch die Umwandlung, sicherheitshalber mit 400. */
|
||||
function seitDate(seit: string): Date {
|
||||
const date = parseSeit(seit);
|
||||
if (!date) {
|
||||
throw new BadRequestException(
|
||||
'seit muss JJJJ-MM-TT oder JJJJ-MM-TTThh:mm[:ss[.sss]] mit Z oder ±hh:mm sein',
|
||||
);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { isPlausibleErpDate, parseSeit } from './erp-time';
|
||||
|
||||
describe('parseSeit', () => {
|
||||
it.each([
|
||||
['2026-01-01', '2026-01-01T00:00:00.000Z'],
|
||||
['2026-01-01T00:00Z', '2026-01-01T00:00:00.000Z'],
|
||||
['2026-01-01T08:15:30Z', '2026-01-01T08:15:30.000Z'],
|
||||
['2026-01-01T08:15:30.1Z', '2026-01-01T08:15:30.100Z'],
|
||||
['2026-01-01T08:15:30.123Z', '2026-01-01T08:15:30.123Z'],
|
||||
['2026-01-01T01:00:00+01:00', '2026-01-01T00:00:00.000Z'],
|
||||
['2025-12-31T21:30:00-02:30', '2026-01-01T00:00:00.000Z'],
|
||||
])('liest %s als %s (UTC)', (seit, iso) => {
|
||||
expect(parseSeit(seit)?.toISOString()).toBe(iso);
|
||||
});
|
||||
|
||||
it.each([
|
||||
'2026-W01',
|
||||
'20260101',
|
||||
'-2026-01-01',
|
||||
'2026-001',
|
||||
'2026-01-01T00:00',
|
||||
'2026-01-01T24:00Z',
|
||||
'2026-02-29',
|
||||
'2026-04-31',
|
||||
'1999-12-31T23:59:59Z',
|
||||
'2101-01-01',
|
||||
'2100-12-31T23:00:00-02:00',
|
||||
])('lehnt %s ab', (seit) => {
|
||||
expect(parseSeit(seit)).toBeNull();
|
||||
});
|
||||
|
||||
it('kennt Schalttage', () => {
|
||||
expect(parseSeit('2028-02-29')?.toISOString()).toBe(
|
||||
'2028-02-29T00:00:00.000Z',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPlausibleErpDate', () => {
|
||||
it('lässt nur die Jahre 2000 bis 2100 (UTC) zu', () => {
|
||||
expect(isPlausibleErpDate(new Date('2000-01-01T00:00:00.000Z'))).toBe(true);
|
||||
expect(isPlausibleErpDate(new Date('2100-12-31T23:59:59.999Z'))).toBe(true);
|
||||
expect(isPlausibleErpDate(new Date('1999-12-31T23:59:59.999Z'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPlausibleErpDate(new Date('2101-01-01T00:00:00.000Z'))).toBe(
|
||||
false,
|
||||
);
|
||||
expect(isPlausibleErpDate(new Date(Number.NaN))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Zeitpunkte, die der ERP-Export aus Anfragen übernimmt (`seit`, Cursor).
|
||||
* Nur plausible Jahre: JavaScript kennt Jahre bis +275760, die
|
||||
* Datenbankabfrage nicht - das wäre ein 500 statt eines 400.
|
||||
*/
|
||||
export const ERP_MIN_YEAR = 2000;
|
||||
export const ERP_MAX_YEAR = 2100;
|
||||
|
||||
export function isPlausibleErpDate(date: Date): boolean {
|
||||
if (Number.isNaN(date.getTime())) return false;
|
||||
const year = date.getUTCFullYear();
|
||||
return year >= ERP_MIN_YEAR && year <= ERP_MAX_YEAR;
|
||||
}
|
||||
|
||||
/**
|
||||
* Die einzigen Formen für `seit`: ein Kalendertag (`JJJJ-MM-TT`, gilt als
|
||||
* Mitternacht UTC) oder ein Zeitpunkt mit ausdrücklicher Zone
|
||||
* (`JJJJ-MM-TTThh:mm[:ss[.sss]]` mit `Z` oder `±hh:mm`). Andere ISO-8601-
|
||||
* Formen (Wochen, Ordinaltage, Kurzform ohne Striche, erweiterte Jahre,
|
||||
* Uhrzeit ohne Zone = Ortszeit des Servers) werden abgelehnt, statt falsch
|
||||
* gelesen zu werden oder als 500 in der Datenbank zu enden.
|
||||
*/
|
||||
export const SEIT_PATTERN =
|
||||
/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?(Z|([+-])(\d{2}):(\d{2})))?$/;
|
||||
|
||||
const MS_PER_MINUTE = 60_000;
|
||||
|
||||
/** Der Zeitpunkt zu `seit`, oder null, wenn Form, Kalender oder Jahr nicht passen. */
|
||||
export function parseSeit(value: string): Date | null {
|
||||
const match = SEIT_PATTERN.exec(value);
|
||||
if (!match) return null;
|
||||
// Optionale Gruppen ohne Treffer sind undefined.
|
||||
const groups: (string | undefined)[] = [...match];
|
||||
const [, y, mo, d, h, mi, s, frac, , sign, oh, om] = groups;
|
||||
const year = Number(y);
|
||||
const month = Number(mo) - 1;
|
||||
const day = Number(d);
|
||||
const hours = Number(h ?? 0);
|
||||
const minutes = Number(mi ?? 0);
|
||||
const seconds = Number(s ?? 0);
|
||||
const offsetHours = Number(oh ?? 0);
|
||||
const offsetMinutes = Number(om ?? 0);
|
||||
if (
|
||||
hours > 23 ||
|
||||
minutes > 59 ||
|
||||
seconds > 59 ||
|
||||
offsetHours > 23 ||
|
||||
offsetMinutes > 59
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const calendarDay = new Date(Date.UTC(year, month, day));
|
||||
if (
|
||||
calendarDay.getUTCFullYear() !== year ||
|
||||
calendarDay.getUTCMonth() !== month ||
|
||||
calendarDay.getUTCDate() !== day
|
||||
) {
|
||||
return null; // z. B. 2026-02-30
|
||||
}
|
||||
const millis = Number((frac ?? '').padEnd(3, '0'));
|
||||
const offset =
|
||||
(sign === '-' ? -1 : 1) *
|
||||
(offsetHours * 60 + offsetMinutes) *
|
||||
MS_PER_MINUTE;
|
||||
const date = new Date(
|
||||
Date.UTC(year, month, day, hours, minutes, seconds, millis) - offset,
|
||||
);
|
||||
return isPlausibleErpDate(date) ? date : null;
|
||||
}
|
||||
@@ -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),
|
||||
}));
|
||||
}
|
||||
Reference in new issue
Block a user