From cf625552d18c3b3b30267beea5fea87d2e63f9e2 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 00:37:09 +0200 Subject: [PATCH] feat(backend): Honigchargen-Modul (Modul 11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HoneyBatchesModule inkl. Honigmessungen und Rückverfolgbarkeits-Endpunkt, der eine Charge über die Schleuderung und Ernte bis zu den Ursprungsvölkern zurückverfolgt. --- ApiCore-Backend/src/app.module.ts | 2 + .../dto/create-honey-batch.dto.ts | 32 ++++ .../dto/create-honey-measurement.dto.ts | 27 ++++ .../honey-batches/honey-batches.controller.ts | 50 ++++++ .../src/honey-batches/honey-batches.module.ts | 10 ++ .../honey-batches.service.spec.ts | 98 ++++++++++++ .../honey-batches/honey-batches.service.ts | 109 +++++++++++++ .../test/honey-batches.e2e-spec.ts | 145 ++++++++++++++++++ TASKS.md | 2 +- 9 files changed, 474 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts create mode 100644 ApiCore-Backend/src/honey-batches/dto/create-honey-measurement.dto.ts create mode 100644 ApiCore-Backend/src/honey-batches/honey-batches.controller.ts create mode 100644 ApiCore-Backend/src/honey-batches/honey-batches.module.ts create mode 100644 ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts create mode 100644 ApiCore-Backend/src/honey-batches/honey-batches.service.ts create mode 100644 ApiCore-Backend/test/honey-batches.e2e-spec.ts diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 0c8a36f..8a2944d 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -16,6 +16,7 @@ import { TreatmentsModule } from './treatments/treatments.module'; import { FeedingsModule } from './feedings/feedings.module'; import { HarvestsModule } from './harvests/harvests.module'; import { ExtractionsModule } from './extractions/extractions.module'; +import { HoneyBatchesModule } from './honey-batches/honey-batches.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -37,6 +38,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; FeedingsModule, HarvestsModule, ExtractionsModule, + HoneyBatchesModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts b/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts new file mode 100644 index 0000000..556d0f5 --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/dto/create-honey-batch.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class CreateHoneyBatchDto { + @ApiProperty() + @IsString() + batchNumber!: string; + + @ApiProperty() + @IsString() + extractionId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + honeyType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + amount?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + waterContent?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + storageContainerId?: string; +} diff --git a/ApiCore-Backend/src/honey-batches/dto/create-honey-measurement.dto.ts b/ApiCore-Backend/src/honey-batches/dto/create-honey-measurement.dto.ts new file mode 100644 index 0000000..1622202 --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/dto/create-honey-measurement.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsNumber, IsOptional, IsString } from 'class-validator'; + +export class CreateHoneyMeasurementDto { + @ApiProperty() + @IsNumber() + waterContent!: number; + + @ApiProperty() + @IsDateString() + measuredAt!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + device?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + temperature?: number; + + @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 new file mode 100644 index 0000000..a5b07ae --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/honey-batches.controller.ts @@ -0,0 +1,50 @@ +import { Body, Controller, Get, Param, Post } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { Role } from '../../generated/prisma'; +import { Roles } from '../auth/decorators/roles.decorator'; +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 { HoneyBatchesService } from './honey-batches.service'; + +@ApiTags('honey-batches') +@ApiBearerAuth() +@Controller('honey-batches') +export class HoneyBatchesController { + constructor(private readonly honeyBatchesService: HoneyBatchesService) {} + + @Get() + findAll() { + return this.honeyBatchesService.findAll(); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.honeyBatchesService.findById(id); + } + + @Get(':id/traceability') + traceability(@Param('id') id: string) { + return this.honeyBatchesService.traceability(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateHoneyBatchDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.honeyBatchesService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post(':id/measurements') + addMeasurement( + @Param('id') id: string, + @Body() dto: CreateHoneyMeasurementDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.honeyBatchesService.addMeasurement(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.module.ts b/ApiCore-Backend/src/honey-batches/honey-batches.module.ts new file mode 100644 index 0000000..433736c --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/honey-batches.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { HoneyBatchesController } from './honey-batches.controller'; +import { HoneyBatchesService } from './honey-batches.service'; + +@Module({ + controllers: [HoneyBatchesController], + providers: [HoneyBatchesService], + exports: [HoneyBatchesService], +}) +export class HoneyBatchesModule {} diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts new file mode 100644 index 0000000..d6642ff --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.spec.ts @@ -0,0 +1,98 @@ +import { NotFoundException } from '@nestjs/common'; +import { HoneyBatchesService } from './honey-batches.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('HoneyBatchesService', () => { + let service: HoneyBatchesService; + let prisma: { + honeyBatch: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + }; + honeyMeasurement: { create: jest.Mock }; + }; + let auditLog: { record: jest.Mock }; + + const extraction = { + id: 'extraction-1', + date: new Date(), + room: 'Raum 1', + harvests: [ + { + harvest: { + id: 'harvest-1', + date: new Date(), + apiaryId: 'apiary-1', + colonies: [{ colony: { id: 'colony-1', code: 'V-001' } }], + }, + }, + ], + }; + const honeyBatch = { + id: 'batch-1', + batchNumber: 'HC-2026-001', + honeyType: 'Blütenhonig', + amount: 20, + status: 'RAW', + measurements: [], + extraction, + }; + + beforeEach(() => { + prisma = { + honeyBatch: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + honeyMeasurement: { create: jest.fn() }, + }; + auditLog = { record: jest.fn() }; + service = new HoneyBatchesService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findById throws NotFoundException when missing', async () => { + prisma.honeyBatch.findUnique.mockResolvedValue(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('create stores the batch and returns it with relations', async () => { + prisma.honeyBatch.create.mockResolvedValue(honeyBatch); + prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); + + await service.create( + { batchNumber: 'HC-2026-001', extractionId: 'extraction-1' }, + 'user-1', + ); + + const [[createArg]] = prisma.honeyBatch.create.mock.calls as [ + [{ data: { batchNumber: string; extractionId: string } }], + ]; + expect(createArg.data.batchNumber).toBe('HC-2026-001'); + expect(createArg.data.extractionId).toBe('extraction-1'); + }); + + it('traceability flattens the extraction, harvest, and colony chain', async () => { + prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch); + + const result = await service.traceability('batch-1'); + + expect(result.honeyBatch.batchNumber).toBe('HC-2026-001'); + expect(result.extraction.id).toBe('extraction-1'); + expect(result.harvests).toEqual([ + { + id: 'harvest-1', + date: extraction.harvests[0].harvest.date, + apiaryId: 'apiary-1', + colonies: [{ id: 'colony-1', code: 'V-001' }], + }, + ]); + }); +}); diff --git a/ApiCore-Backend/src/honey-batches/honey-batches.service.ts b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts new file mode 100644 index 0000000..58b4343 --- /dev/null +++ b/ApiCore-Backend/src/honey-batches/honey-batches.service.ts @@ -0,0 +1,109 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto'; +import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto'; + +const honeyBatchInclude = { + measurements: true, + extraction: { + include: { + harvests: { + include: { + harvest: { include: { colonies: { include: { colony: true } } } }, + }, + }, + }, + }, +} as const; + +@Injectable() +export class HoneyBatchesService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll() { + return this.prisma.honeyBatch.findMany({ + include: honeyBatchInclude, + orderBy: { createdAt: 'desc' }, + }); + } + + async findById(id: string) { + const honeyBatch = await this.prisma.honeyBatch.findUnique({ + where: { id }, + include: honeyBatchInclude, + }); + if (!honeyBatch) { + throw new NotFoundException('Honey batch not found'); + } + return honeyBatch; + } + + async create(dto: CreateHoneyBatchDto, actingUserId: string) { + const honeyBatch = await this.prisma.honeyBatch.create({ + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'HoneyBatch', + entityId: honeyBatch.id, + action: 'CREATE', + userId: actingUserId, + afterData: honeyBatch, + }); + return this.findById(honeyBatch.id); + } + + async addMeasurement( + id: string, + dto: CreateHoneyMeasurementDto, + actingUserId: string, + ) { + await this.findById(id); + const measurement = await this.prisma.honeyMeasurement.create({ + data: { + ...dto, + measuredAt: new Date(dto.measuredAt), + honeyBatchId: id, + userId: actingUserId, + }, + }); + await this.auditLog.record({ + entityType: 'HoneyMeasurement', + entityId: measurement.id, + action: 'CREATE', + userId: actingUserId, + afterData: measurement, + }); + return this.findById(id); + } + + async traceability(id: string) { + const honeyBatch = await this.findById(id); + return { + honeyBatch: { + id: honeyBatch.id, + batchNumber: honeyBatch.batchNumber, + honeyType: honeyBatch.honeyType, + amount: honeyBatch.amount, + status: honeyBatch.status, + }, + extraction: { + id: honeyBatch.extraction.id, + date: honeyBatch.extraction.date, + room: honeyBatch.extraction.room, + }, + harvests: honeyBatch.extraction.harvests.map((entry) => ({ + id: entry.harvest.id, + date: entry.harvest.date, + apiaryId: entry.harvest.apiaryId, + colonies: entry.harvest.colonies.map((hc) => ({ + id: hc.colony.id, + code: hc.colony.code, + })), + })), + }; + } +} diff --git a/ApiCore-Backend/test/honey-batches.e2e-spec.ts b/ApiCore-Backend/test/honey-batches.e2e-spec.ts new file mode 100644 index 0000000..780f5c5 --- /dev/null +++ b/ApiCore-Backend/test/honey-batches.e2e-spec.ts @@ -0,0 +1,145 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface HoneyBatchBody { + id: string; + batchNumber: string; + measurements: { id: string }[]; +} + +interface TraceabilityBody { + honeyBatch: { batchNumber: string }; + extraction: { id: string }; + harvests: { colonies: { code: string }[] }[]; +} + +describe('Honey batches (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `honey-batches-e2e-beekeeper-${Date.now()}@example.com`; + const staffEmail = `honey-batches-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let staffId: string; + let beekeeperToken: string; + let staffToken: string; + let apiaryId: string; + let colonyId: string; + let harvestId: string; + let extractionId: string; + let honeyBatchId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + const staff = await prisma.user.create({ + data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, + }); + staffId = staff.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + staffToken = (await login(app, staffEmail, password)).accessToken; + + const apiary = await prisma.apiary.create({ + data: { name: `Stand ${Date.now()}` }, + }); + apiaryId = apiary.id; + const colony = await prisma.colony.create({ + data: { + code: `V-${Date.now()}`, + apiaryId, + colonyType: 'PRODUCTION_COLONY', + }, + }); + colonyId = colony.id; + const harvest = await prisma.harvest.create({ + data: { apiaryId, date: new Date(), userId: beekeeperId }, + }); + harvestId = harvest.id; + await prisma.harvestColony.create({ data: { harvestId, colonyId } }); + const extraction = await prisma.extraction.create({ + data: { date: new Date(), userId: beekeeperId }, + }); + extractionId = extraction.id; + await prisma.extractionHarvest.create({ + data: { extractionId, harvestId }, + }); + }); + + afterAll(async () => { + if (honeyBatchId) { + await prisma.honeyMeasurement.deleteMany({ where: { honeyBatchId } }); + await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } }); + } + await prisma.extractionHarvest.deleteMany({ where: { extractionId } }); + await prisma.extraction.deleteMany({ where: { id: extractionId } }); + await prisma.harvestColony.deleteMany({ where: { harvestId } }); + await prisma.harvest.deleteMany({ where: { id: harvestId } }); + await prisma.colony.deleteMany({ where: { id: colonyId } }); + await prisma.apiary.deleteMany({ where: { id: apiaryId } }); + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [beekeeperId, staffId] } }, + }); + await app.close(); + }); + + it('rejects create for a STAFF user', () => { + return request(app.getHttpServer()) + .post('/api/v1/honey-batches') + .set('Authorization', `Bearer ${staffToken}`) + .send({ batchNumber: `HC-${Date.now()}`, extractionId }) + .expect(403); + }); + + it('lets a BEEKEEPER user create a honey batch from an extraction', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/honey-batches') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ batchNumber: `HC-${Date.now()}`, extractionId, amount: 20 }) + .expect(201); + const honeyBatch = response.body as HoneyBatchBody; + honeyBatchId = honeyBatch.id; + expect(honeyBatch.batchNumber).toEqual(expect.stringContaining('HC-')); + }); + + it('adds a water content measurement to the batch', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/honey-batches/${honeyBatchId}/measurements`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ waterContent: 17.4, measuredAt: new Date().toISOString() }) + .expect(201); + const honeyBatch = response.body as HoneyBatchBody; + expect(honeyBatch.measurements).toHaveLength(1); + }); + + it('traces the honey batch back to its source colonies', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/honey-batches/${honeyBatchId}/traceability`) + .set('Authorization', `Bearer ${staffToken}`) + .expect(200); + const trace = response.body as TraceabilityBody; + expect(trace.extraction.id).toBe(extractionId); + expect(trace.harvests[0]?.colonies[0]?.code).toBeDefined(); + }); +}); diff --git a/TASKS.md b/TASKS.md index 7c60e19..2a794b7 100644 --- a/TASKS.md +++ b/TASKS.md @@ -98,7 +98,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `HoneyBatchesModule` (inkl. Honigmessungen, siehe ARCHITECTURE.md Annahme 1) - Endpunkte: `GET/POST /api/v1/honey-batches`, `GET /api/v1/honey-batches/:id`, `GET /api/v1/honey-batches/:id/traceability`, `POST /api/v1/honey-batches/:id/measurements` - Frontend: `/honey-batches`, `/honey-batches/[id]` (inkl. Rückverfolgbarkeits-Ansicht) -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 12. Grundlegende Lagerverwaltung