diff --git a/ApiCore-Backend/prisma/migrations/20260824144745_add_honey_batch_relocation/migration.sql b/ApiCore-Backend/prisma/migrations/20260824144745_add_honey_batch_relocation/migration.sql new file mode 100644 index 0000000..3d1c039 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260824144745_add_honey_batch_relocation/migration.sql @@ -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; diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index f142f5c..eeb2636 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -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[] diff --git a/ApiCore-Backend/src/honey-batches/dto/relocate-honey-batch.dto.ts b/ApiCore-Backend/src/honey-batches/dto/relocate-honey-batch.dto.ts new file mode 100644 index 0000000..ae9afb1 --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/dto/relocate-honey-batch.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts index ea100b6..96ddb19 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts @@ -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( diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts index 5e7ac08..2effcd0 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts @@ -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, + ): Promise => 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'); + }); + }); }); diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts index 12b2fa6..8f1207f 100644 --- a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -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 { diff --git a/ApiCore-Backend/test/honey-batches.e2e-spec.ts b/ApiCore-Backend/test/honey-batches.e2e-spec.ts index 780f5c5..bbc0797 100644 --- a/ApiCore-Backend/test/honey-batches.e2e-spec.ts +++ b/ApiCore-Backend/test/honey-batches.e2e-spec.ts @@ -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(); + }); });