feat(backend): Lagerbehälter-Umlagerung für Honigchargen (Modul 32)

Neues HoneyBatchRelocation-Modell dokumentiert jede Umlagerung einer
Honigcharge zwischen Lagerbehältern (von/nach, Zeitpunkt, Benutzer,
Notiz). HoneyBatchesService.relocate() aktualisiert
HoneyBatch.storageContainerId transaktional zusammen mit dem
Historieneintrag; wirft NotFoundException bei unbekanntem Zielbehälter.

Neue Endpunkte: POST /honey-batches/:id/relocate,
GET /honey-batches/:id/relocations. honeyBatchInclude liefert jetzt
zusätzlich den aktuell zugeordneten storageContainer mit.

Unit- und E2E-Tests ergänzt (Mehrfachumlagerung, Historienreihenfolge,
404 bei unbekanntem Behälter). Migration auf Dev- und Test-Datenbank
angewendet.
This commit is contained in:
Nick Szaunig committed 2026-08-24 16:55:15 +02:00
1 parent fa13543578
commit 59a320ae36
7 files changed
+328 -44

No files matched your search

@@ -0,0 +1,14 @@
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
export class RelocateHoneyBatchDto {
@ApiProperty()
@IsString()
@IsNotEmpty()
storageContainerId!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
notes?: string;
}
@@ -6,6 +6,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { HoneyBatchesService } from './honey-batches.service';
@ApiTags('honey-batches')
@@ -29,6 +30,21 @@ export class HoneyBatchesController {
return this.honeyBatchesService.traceability(id);
}
@Get(':id/relocations')
findRelocations(@Param('id') id: string) {
return this.honeyBatchesService.findRelocations(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Post(':id/relocate')
relocate(
@Param('id') id: string,
@Body() dto: RelocateHoneyBatchDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.honeyBatchesService.relocate(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER)
@Post()
create(
@@ -11,8 +11,12 @@ describe('HoneyBatchesService', () => {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
honeyMeasurement: { create: jest.Mock };
storageContainer: { findUnique: jest.Mock };
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
@@ -47,8 +51,12 @@ describe('HoneyBatchesService', () => {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
honeyMeasurement: { create: jest.fn() },
storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
$transaction: jest.fn(),
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
@@ -98,4 +106,94 @@ describe('HoneyBatchesService', () => {
},
]);
});
describe('relocate', () => {
beforeEach(() => {
prisma.$transaction.mockImplementation(
async (
callback: (tx: typeof prisma) => Promise<unknown>,
): Promise<unknown> => callback(prisma),
);
});
it('throws NotFoundException when the target container does not exist', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
storageContainerId: null,
});
prisma.storageContainer.findUnique.mockResolvedValue(null);
await expect(
service.relocate(
'batch-1',
{ storageContainerId: 'missing' },
'user-1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('records the relocation and updates the batch container', async () => {
prisma.honeyBatch.findUnique
.mockResolvedValueOnce({
...honeyBatch,
storageContainerId: 'container-old',
})
.mockResolvedValueOnce({
...honeyBatch,
storageContainerId: 'container-new',
});
prisma.storageContainer.findUnique.mockResolvedValue({
id: 'container-new',
});
prisma.honeyBatchRelocation.create.mockResolvedValue({
id: 'relocation-1',
honeyBatchId: 'batch-1',
fromStorageContainerId: 'container-old',
toStorageContainerId: 'container-new',
});
await service.relocate(
'batch-1',
{ storageContainerId: 'container-new', notes: 'Umzug ins Lager 2' },
'user-1',
);
const [[createArg]] = prisma.honeyBatchRelocation.create.mock.calls as [
[
{
data: {
fromStorageContainerId: string | null;
toStorageContainerId: string;
};
},
],
];
expect(createArg.data.fromStorageContainerId).toBe('container-old');
expect(createArg.data.toStorageContainerId).toBe('container-new');
const [[updateArg]] = prisma.honeyBatch.update.mock.calls as [
[{ data: { storageContainerId: string } }],
];
expect(updateArg.data.storageContainerId).toBe('container-new');
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({ entityType: 'HoneyBatchRelocation' }),
);
});
});
describe('findRelocations', () => {
it('returns the relocation history ordered by date', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
prisma.honeyBatchRelocation.findMany.mockResolvedValue([
{ id: 'relocation-1', honeyBatchId: 'batch-1' },
]);
const result = await service.findRelocations('batch-1');
expect(result).toEqual([{ id: 'relocation-1', honeyBatchId: 'batch-1' }]);
const [[findArg]] = prisma.honeyBatchRelocation.findMany.mock.calls as [
[{ where: { honeyBatchId: string } }],
];
expect(findArg.where.honeyBatchId).toBe('batch-1');
});
});
});
@@ -5,9 +5,11 @@ import { WebhooksService } from '../webhooks/webhooks.service';
import { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
const honeyBatchInclude = {
measurements: true,
storageContainer: true,
extraction: {
include: {
harvests: {
@@ -90,6 +92,50 @@ export class HoneyBatchesService {
return this.findById(id);
}
async relocate(id: string, dto: RelocateHoneyBatchDto, actingUserId: string) {
const honeyBatch = await this.findById(id);
const targetContainer = await this.prisma.storageContainer.findUnique({
where: { id: dto.storageContainerId },
});
if (!targetContainer) {
throw new NotFoundException('Storage container not found');
}
const relocation = await this.prisma.$transaction(async (tx) => {
const created = await tx.honeyBatchRelocation.create({
data: {
honeyBatchId: id,
fromStorageContainerId: honeyBatch.storageContainerId,
toStorageContainerId: dto.storageContainerId,
userId: actingUserId,
notes: dto.notes,
},
});
await tx.honeyBatch.update({
where: { id },
data: { storageContainerId: dto.storageContainerId },
});
return created;
});
await this.auditLog.record({
entityType: 'HoneyBatchRelocation',
entityId: relocation.id,
action: 'CREATE',
userId: actingUserId,
afterData: relocation,
});
return this.findById(id);
}
async findRelocations(id: string) {
await this.findById(id);
return this.prisma.honeyBatchRelocation.findMany({
where: { honeyBatchId: id },
include: { fromStorageContainer: true, toStorageContainer: true },
orderBy: { relocatedAt: 'desc' },
});
}
async traceability(id: string) {
const honeyBatch = await this.findById(id);
return {