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[] refreshTokens RefreshToken[]
inspections Inspection[] inspections Inspection[]
varroaMeasurements VarroaMeasurement[] varroaMeasurements VarroaMeasurement[]
treatments Treatment[] treatments Treatment[]
feedings Feeding[] feedings Feeding[]
harvests Harvest[] harvests Harvest[]
extractions Extraction[] extractions Extraction[]
honeyMeasurements HoneyMeasurement[] honeyMeasurements HoneyMeasurement[]
stockMovements StockMovement[] stockMovements StockMovement[]
assignedTasks Task[] @relation("TaskAssignee") assignedTasks Task[] @relation("TaskAssignee")
createdTasks Task[] @relation("TaskCreator") createdTasks Task[] @relation("TaskCreator")
colonyMoves ColonyApiaryHistory[] colonyMoves ColonyApiaryHistory[]
auditLogs AuditLog[] auditLogs AuditLog[]
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
notifications Notification[] notifications Notification[]
queenEvaluations QueenEvaluation[] queenEvaluations QueenEvaluation[]
webhookSubscriptions WebhookSubscription[] webhookSubscriptions WebhookSubscription[]
documents Document[] documents Document[]
honeyBatchRelocations HoneyBatchRelocation[]
@@index([role]) @@index([role])
} }
@@ -266,22 +267,22 @@ model Queen {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
model QueenEvaluation { model QueenEvaluation {
id String @id @default(cuid()) id String @id @default(cuid())
queenId String queenId String
queen Queen @relation(fields: [queenId], references: [id]) queen Queen @relation(fields: [queenId], references: [id])
colonyId String? colonyId String?
colony Colony? @relation(fields: [colonyId], references: [id]) colony Colony? @relation(fields: [colonyId], references: [id])
date DateTime date DateTime
colonyStrength Int colonyStrength Int
gentleness Int gentleness Int
combBuild Int combBuild Int
swarmingTendency Int swarmingTendency Int
broodPattern Int broodPattern Int
honeyYield Int honeyYield Int
notes String? notes String?
userId String? userId String?
user User? @relation(fields: [userId], references: [id]) user User? @relation(fields: [userId], references: [id])
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([queenId]) @@index([queenId])
@@index([colonyId]) @@index([colonyId])
@@ -563,6 +564,7 @@ model HoneyBatch {
measurements HoneyMeasurement[] measurements HoneyMeasurement[]
bottlingBatches BottlingBatch[] bottlingBatches BottlingBatch[]
relocations HoneyBatchRelocation[]
@@index([status]) @@index([status])
@@index([extractionId]) @@index([extractionId])
@@ -608,11 +610,31 @@ model StorageContainer {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
honeyBatches HoneyBatch[] honeyBatches HoneyBatch[]
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
@@index([status]) @@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 { enum StockCategory {
HONEY HONEY
CONSUMABLE CONSUMABLE
@@ -669,16 +691,16 @@ enum ProductStatus {
} }
model Product { model Product {
id String @id @default(cuid()) id String @id @default(cuid())
name String name String
description String? description String?
sku String? @unique sku String? @unique
packagingSize Float? packagingSize Float?
weight Float? weight Float?
salesUnit String? salesUnit String?
status ProductStatus @default(ACTIVE) status ProductStatus @default(ACTIVE)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
bottlingBatches BottlingBatch[] 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 type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto'; import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { HoneyBatchesService } from './honey-batches.service'; import { HoneyBatchesService } from './honey-batches.service';
@ApiTags('honey-batches') @ApiTags('honey-batches')
@@ -29,6 +30,21 @@ export class HoneyBatchesController {
return this.honeyBatchesService.traceability(id); 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) @Roles(Role.ADMIN, Role.BEEKEEPER)
@Post() @Post()
create( create(
@@ -11,8 +11,12 @@ describe('HoneyBatchesService', () => {
findMany: jest.Mock; findMany: jest.Mock;
findUnique: jest.Mock; findUnique: jest.Mock;
create: jest.Mock; create: jest.Mock;
update: jest.Mock;
}; };
honeyMeasurement: { create: 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 }; let auditLog: { record: jest.Mock };
@@ -47,8 +51,12 @@ describe('HoneyBatchesService', () => {
findMany: jest.fn(), findMany: jest.fn(),
findUnique: jest.fn(), findUnique: jest.fn(),
create: jest.fn(), create: jest.fn(),
update: jest.fn(),
}, },
honeyMeasurement: { create: jest.fn() }, honeyMeasurement: { create: jest.fn() },
storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
$transaction: jest.fn(),
}; };
auditLog = { record: jest.fn() }; auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) }; 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 { HoneyBatchStatus } from '../../generated/prisma';
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto'; import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
const honeyBatchInclude = { const honeyBatchInclude = {
measurements: true, measurements: true,
storageContainer: true,
extraction: { extraction: {
include: { include: {
harvests: { harvests: {
@@ -90,6 +92,50 @@ export class HoneyBatchesService {
return this.findById(id); 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) { async traceability(id: string) {
const honeyBatch = await this.findById(id); const honeyBatch = await this.findById(id);
return { return {
@@ -10,6 +10,13 @@ interface HoneyBatchBody {
id: string; id: string;
batchNumber: string; batchNumber: string;
measurements: { id: string }[]; measurements: { id: string }[];
storageContainerId: string | null;
}
interface RelocationBody {
id: string;
fromStorageContainerId: string | null;
toStorageContainerId: string;
} }
interface TraceabilityBody { interface TraceabilityBody {
@@ -33,6 +40,8 @@ describe('Honey batches (e2e)', () => {
let harvestId: string; let harvestId: string;
let extractionId: string; let extractionId: string;
let honeyBatchId: string; let honeyBatchId: string;
let containerAId: string;
let containerBId: string;
beforeAll(async () => { beforeAll(async () => {
app = await createTestApp(); app = await createTestApp();
@@ -79,13 +88,27 @@ describe('Honey batches (e2e)', () => {
await prisma.extractionHarvest.create({ await prisma.extractionHarvest.create({
data: { extractionId, harvestId }, 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 () => { afterAll(async () => {
if (honeyBatchId) { if (honeyBatchId) {
await prisma.honeyBatchRelocation.deleteMany({
where: { honeyBatchId },
});
await prisma.honeyMeasurement.deleteMany({ where: { honeyBatchId } }); await prisma.honeyMeasurement.deleteMany({ where: { honeyBatchId } });
await prisma.honeyBatch.deleteMany({ where: { id: 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.extractionHarvest.deleteMany({ where: { extractionId } });
await prisma.extraction.deleteMany({ where: { id: extractionId } }); await prisma.extraction.deleteMany({ where: { id: extractionId } });
await prisma.harvestColony.deleteMany({ where: { harvestId } }); await prisma.harvestColony.deleteMany({ where: { harvestId } });
@@ -142,4 +165,41 @@ describe('Honey batches (e2e)', () => {
expect(trace.extraction.id).toBe(extractionId); expect(trace.extraction.id).toBe(extractionId);
expect(trace.harvests[0]?.colonies[0]?.code).toBeDefined(); 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();
});
}); });