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,28 @@
-- CreateTable
CREATE TABLE "HoneyBatchRelocation" (
"id" TEXT NOT NULL,
"honeyBatchId" TEXT NOT NULL,
"fromStorageContainerId" TEXT,
"toStorageContainerId" TEXT NOT NULL,
"userId" TEXT,
"notes" TEXT,
"relocatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "HoneyBatchRelocation_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "HoneyBatchRelocation_honeyBatchId_relocatedAt_idx" ON "HoneyBatchRelocation"("honeyBatchId", "relocatedAt");
-- AddForeignKey
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_fromStorageContainerId_fkey" FOREIGN KEY ("fromStorageContainerId") REFERENCES "StorageContainer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_toStorageContainerId_fkey" FOREIGN KEY ("toStorageContainerId") REFERENCES "StorageContainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
+66 -44
View File
@@ -37,23 +37,24 @@ model User {
refreshTokens RefreshToken[]
inspections Inspection[]
varroaMeasurements VarroaMeasurement[]
treatments Treatment[]
feedings Feeding[]
harvests Harvest[]
extractions Extraction[]
honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
inspections Inspection[]
varroaMeasurements VarroaMeasurement[]
treatments Treatment[]
feedings Feeding[]
harvests Harvest[]
extractions Extraction[]
honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator")
colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[]
bottlingBatches BottlingBatch[]
notifications Notification[]
queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[]
documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
@@index([role])
}
@@ -266,22 +267,22 @@ model Queen {
// ---------------------------------------------------------------------------
model QueenEvaluation {
id String @id @default(cuid())
queenId String
queen Queen @relation(fields: [queenId], references: [id])
colonyId String?
colony Colony? @relation(fields: [colonyId], references: [id])
date DateTime
colonyStrength Int
gentleness Int
combBuild Int
swarmingTendency Int
broodPattern Int
honeyYield Int
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
id String @id @default(cuid())
queenId String
queen Queen @relation(fields: [queenId], references: [id])
colonyId String?
colony Colony? @relation(fields: [colonyId], references: [id])
date DateTime
colonyStrength Int
gentleness Int
combBuild Int
swarmingTendency Int
broodPattern Int
honeyYield Int
notes String?
userId String?
user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now())
@@index([queenId])
@@index([colonyId])
@@ -563,6 +564,7 @@ model HoneyBatch {
measurements HoneyMeasurement[]
bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[]
@@index([status])
@@index([extractionId])
@@ -608,11 +610,31 @@ model StorageContainer {
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
honeyBatches HoneyBatch[]
honeyBatches HoneyBatch[]
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
@@index([status])
}
// Umlagerungshistorie einer Honigcharge zwischen Lagerbehältern (Abschnitt 26, v1.x Modul 32)
model HoneyBatchRelocation {
id String @id @default(cuid())
honeyBatchId String
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
fromStorageContainerId String?
fromStorageContainer StorageContainer? @relation("RelocationFrom", fields: [fromStorageContainerId], references: [id])
toStorageContainerId String
toStorageContainer StorageContainer @relation("RelocationTo", fields: [toStorageContainerId], references: [id])
userId String?
user User? @relation(fields: [userId], references: [id])
notes String?
relocatedAt DateTime @default(now())
createdAt DateTime @default(now())
@@index([honeyBatchId, relocatedAt])
}
enum StockCategory {
HONEY
CONSUMABLE
@@ -669,16 +691,16 @@ enum ProductStatus {
}
model Product {
id String @id @default(cuid())
name String
description String?
sku String? @unique
packagingSize Float?
weight Float?
salesUnit String?
status ProductStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
id String @id @default(cuid())
name String
description String?
sku String? @unique
packagingSize Float?
weight Float?
salesUnit String?
status ProductStatus @default(ACTIVE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[]
@@ -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 {
@@ -10,6 +10,13 @@ interface HoneyBatchBody {
id: string;
batchNumber: string;
measurements: { id: string }[];
storageContainerId: string | null;
}
interface RelocationBody {
id: string;
fromStorageContainerId: string | null;
toStorageContainerId: string;
}
interface TraceabilityBody {
@@ -33,6 +40,8 @@ describe('Honey batches (e2e)', () => {
let harvestId: string;
let extractionId: string;
let honeyBatchId: string;
let containerAId: string;
let containerBId: string;
beforeAll(async () => {
app = await createTestApp();
@@ -79,13 +88,27 @@ describe('Honey batches (e2e)', () => {
await prisma.extractionHarvest.create({
data: { extractionId, harvestId },
});
const containerA = await prisma.storageContainer.create({
data: { code: `LB-A-${Date.now()}` },
});
containerAId = containerA.id;
const containerB = await prisma.storageContainer.create({
data: { code: `LB-B-${Date.now()}` },
});
containerBId = containerB.id;
});
afterAll(async () => {
if (honeyBatchId) {
await prisma.honeyBatchRelocation.deleteMany({
where: { honeyBatchId },
});
await prisma.honeyMeasurement.deleteMany({ where: { honeyBatchId } });
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
}
await prisma.storageContainer.deleteMany({
where: { id: { in: [containerAId, containerBId] } },
});
await prisma.extractionHarvest.deleteMany({ where: { extractionId } });
await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.harvestColony.deleteMany({ where: { harvestId } });
@@ -142,4 +165,41 @@ describe('Honey batches (e2e)', () => {
expect(trace.extraction.id).toBe(extractionId);
expect(trace.harvests[0]?.colonies[0]?.code).toBeDefined();
});
it('relocates the honey batch into a storage container', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/honey-batches/${honeyBatchId}/relocate`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ storageContainerId: containerAId, notes: 'Erstbefüllung' })
.expect(201);
const honeyBatch = response.body as HoneyBatchBody;
expect(honeyBatch.storageContainerId).toBe(containerAId);
});
it('rejects relocation to a nonexistent storage container', () => {
return request(app.getHttpServer())
.post(`/api/v1/honey-batches/${honeyBatchId}/relocate`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ storageContainerId: 'does-not-exist' })
.expect(404);
});
it('relocates the honey batch again and records both moves in history', async () => {
await request(app.getHttpServer())
.post(`/api/v1/honey-batches/${honeyBatchId}/relocate`)
.set('Authorization', `Bearer ${staffToken}`)
.send({ storageContainerId: containerBId })
.expect(201);
const response = await request(app.getHttpServer())
.get(`/api/v1/honey-batches/${honeyBatchId}/relocations`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(200);
const relocations = response.body as RelocationBody[];
expect(relocations).toHaveLength(2);
expect(relocations[0].toStorageContainerId).toBe(containerBId);
expect(relocations[0].fromStorageContainerId).toBe(containerAId);
expect(relocations[1].toStorageContainerId).toBe(containerAId);
expect(relocations[1].fromStorageContainerId).toBeNull();
});
});