From e82cae257b730c41b137e485458c43ae1ab46e83 Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 11:08:09 +0200 Subject: [PATCH] feat(backend): Abfuellungen und Produkte (Modul 23) ProductsModule (GET/POST /products, GET/PATCH /products/:id) und BottlingModule (GET/POST /bottling-batches) mit transaktionaler Reduzierung der HoneyBatch-Restmenge beim Abfuellen; Ablehnung bei Abfuellmenge > verfuegbarer Menge. Neue Prisma-Modelle Product und BottlingBatch (Migration bottling_products). --- .../migration.sql | 59 ++++++++ ApiCore-Backend/prisma/schema.prisma | 50 ++++++- ApiCore-Backend/src/app.module.ts | 4 + .../src/bottling/bottling.controller.ts | 34 +++++ .../src/bottling/bottling.module.ts | 9 ++ .../src/bottling/bottling.service.spec.ts | 124 ++++++++++++++++ .../src/bottling/bottling.service.ts | 108 ++++++++++++++ .../bottling/dto/create-bottling-batch.dto.ts | 42 ++++++ .../src/products/dto/create-product.dto.ts | 33 +++++ .../src/products/dto/update-product.dto.ts | 11 ++ .../src/products/products.controller.ts | 53 +++++++ .../src/products/products.module.ts | 10 ++ .../src/products/products.service.spec.ts | 74 ++++++++++ .../src/products/products.service.ts | 66 +++++++++ ApiCore-Backend/test/bottling.e2e-spec.ts | 137 ++++++++++++++++++ ApiCore-Backend/test/products.e2e-spec.ts | 102 +++++++++++++ TASKS.md | 2 +- 17 files changed, 916 insertions(+), 2 deletions(-) create mode 100644 ApiCore-Backend/prisma/migrations/20260822090024_bottling_products/migration.sql create mode 100644 ApiCore-Backend/src/bottling/bottling.controller.ts create mode 100644 ApiCore-Backend/src/bottling/bottling.module.ts create mode 100644 ApiCore-Backend/src/bottling/bottling.service.spec.ts create mode 100644 ApiCore-Backend/src/bottling/bottling.service.ts create mode 100644 ApiCore-Backend/src/bottling/dto/create-bottling-batch.dto.ts create mode 100644 ApiCore-Backend/src/products/dto/create-product.dto.ts create mode 100644 ApiCore-Backend/src/products/dto/update-product.dto.ts create mode 100644 ApiCore-Backend/src/products/products.controller.ts create mode 100644 ApiCore-Backend/src/products/products.module.ts create mode 100644 ApiCore-Backend/src/products/products.service.spec.ts create mode 100644 ApiCore-Backend/src/products/products.service.ts create mode 100644 ApiCore-Backend/test/bottling.e2e-spec.ts create mode 100644 ApiCore-Backend/test/products.e2e-spec.ts diff --git a/ApiCore-Backend/prisma/migrations/20260822090024_bottling_products/migration.sql b/ApiCore-Backend/prisma/migrations/20260822090024_bottling_products/migration.sql new file mode 100644 index 0000000..9ad7b18 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260822090024_bottling_products/migration.sql @@ -0,0 +1,59 @@ +-- CreateEnum +CREATE TYPE "ProductStatus" AS ENUM ('ACTIVE', 'DISCONTINUED'); + +-- CreateTable +CREATE TABLE "Product" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "description" TEXT, + "sku" TEXT, + "packagingSize" DOUBLE PRECISION, + "weight" DOUBLE PRECISION, + "salesUnit" TEXT, + "status" "ProductStatus" NOT NULL DEFAULT 'ACTIVE', + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Product_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "BottlingBatch" ( + "id" TEXT NOT NULL, + "honeyBatchId" TEXT NOT NULL, + "productId" TEXT, + "date" TIMESTAMP(3) NOT NULL, + "jarSize" DOUBLE PRECISION NOT NULL, + "jarCount" INTEGER NOT NULL, + "totalAmount" DOUBLE PRECISION NOT NULL, + "bestBeforeDate" TIMESTAMP(3) NOT NULL, + "lotNumber" TEXT NOT NULL, + "userId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "BottlingBatch_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Product_sku_key" ON "Product"("sku"); + +-- CreateIndex +CREATE INDEX "Product_status_idx" ON "Product"("status"); + +-- CreateIndex +CREATE UNIQUE INDEX "BottlingBatch_lotNumber_key" ON "BottlingBatch"("lotNumber"); + +-- CreateIndex +CREATE INDEX "BottlingBatch_honeyBatchId_idx" ON "BottlingBatch"("honeyBatchId"); + +-- CreateIndex +CREATE INDEX "BottlingBatch_productId_idx" ON "BottlingBatch"("productId"); + +-- AddForeignKey +ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_productId_fkey" FOREIGN KEY ("productId") REFERENCES "Product"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "BottlingBatch" ADD CONSTRAINT "BottlingBatch_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 cace213..e8b7679 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -49,6 +49,7 @@ model User { createdTasks Task[] @relation("TaskCreator") colonyMoves ColonyApiaryHistory[] auditLogs AuditLog[] + bottlingBatches BottlingBatch[] @@index([role]) } @@ -528,7 +529,8 @@ model HoneyBatch { createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - measurements HoneyMeasurement[] + measurements HoneyMeasurement[] + bottlingBatches BottlingBatch[] @@index([status]) @@index([extractionId]) @@ -624,3 +626,49 @@ model StockMovement { @@index([stockItemId, date]) } + +// --------------------------------------------------------------------------- +// Abfüllungen und Produkte (Abschnitt 4.19, 4.20) – v1.x Modul 23 +// --------------------------------------------------------------------------- + +enum ProductStatus { + ACTIVE + DISCONTINUED +} + +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 + + bottlingBatches BottlingBatch[] + + @@index([status]) +} + +model BottlingBatch { + id String @id @default(cuid()) + honeyBatchId String + honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id]) + productId String? + product Product? @relation(fields: [productId], references: [id]) + date DateTime + jarSize Float + jarCount Int + totalAmount Float + bestBeforeDate DateTime + lotNumber String @unique + userId String? + user User? @relation(fields: [userId], references: [id]) + createdAt DateTime @default(now()) + + @@index([honeyBatchId]) + @@index([productId]) +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 16ac55b..9d697ea 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -22,6 +22,8 @@ import { DashboardModule } from './dashboard/dashboard.module'; import { SearchModule } from './search/search.module'; import { ExportModule } from './export/export.module'; import { QrModule } from './qr/qr.module'; +import { ProductsModule } from './products/products.module'; +import { BottlingModule } from './bottling/bottling.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -49,6 +51,8 @@ import { RolesGuard } from './auth/guards/roles.guard'; SearchModule, ExportModule, QrModule, + ProductsModule, + BottlingModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/bottling/bottling.controller.ts b/ApiCore-Backend/src/bottling/bottling.controller.ts new file mode 100644 index 0000000..56ddb73 --- /dev/null +++ b/ApiCore-Backend/src/bottling/bottling.controller.ts @@ -0,0 +1,34 @@ +import { Body, Controller, Get, Param, Post, Query } 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 { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; +import { BottlingService } from './bottling.service'; + +@ApiTags('bottling') +@ApiBearerAuth() +@Controller('bottling-batches') +export class BottlingController { + constructor(private readonly bottlingService: BottlingService) {} + + @Get() + findAll(@Query('honeyBatchId') honeyBatchId?: string) { + return this.bottlingService.findAll({ honeyBatchId }); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.bottlingService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateBottlingBatchDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.bottlingService.create(dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/bottling/bottling.module.ts b/ApiCore-Backend/src/bottling/bottling.module.ts new file mode 100644 index 0000000..c9abd12 --- /dev/null +++ b/ApiCore-Backend/src/bottling/bottling.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { BottlingController } from './bottling.controller'; +import { BottlingService } from './bottling.service'; + +@Module({ + controllers: [BottlingController], + providers: [BottlingService], +}) +export class BottlingModule {} diff --git a/ApiCore-Backend/src/bottling/bottling.service.spec.ts b/ApiCore-Backend/src/bottling/bottling.service.spec.ts new file mode 100644 index 0000000..121c7a1 --- /dev/null +++ b/ApiCore-Backend/src/bottling/bottling.service.spec.ts @@ -0,0 +1,124 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { BottlingService } from './bottling.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('BottlingService', () => { + let service: BottlingService; + let prisma: { + honeyBatch: { findUnique: jest.Mock; update: jest.Mock }; + product: { findUnique: jest.Mock }; + bottlingBatch: { + findUnique: jest.Mock; + findMany: jest.Mock; + create: jest.Mock; + }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + let tx: { + bottlingBatch: { create: jest.Mock }; + honeyBatch: { update: jest.Mock }; + }; + + beforeEach(() => { + tx = { + bottlingBatch: { create: jest.fn() }, + honeyBatch: { update: jest.fn() }, + }; + prisma = { + honeyBatch: { findUnique: jest.fn(), update: jest.fn() }, + product: { findUnique: jest.fn() }, + bottlingBatch: { + findUnique: jest.fn(), + findMany: jest.fn(), + create: jest.fn(), + }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn() }; + service = new BottlingService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('throws NotFoundException when the honey batch does not exist', async () => { + prisma.honeyBatch.findUnique.mockResolvedValueOnce(null); + await expect( + service.create( + { + honeyBatchId: 'missing', + date: '2026-01-01', + jarSize: 500, + jarCount: 10, + bestBeforeDate: '2027-01-01', + lotNumber: 'L-1', + }, + 'user-1', + ), + ).rejects.toBeInstanceOf(NotFoundException); + }); + + it('rejects when the bottled amount exceeds the available honey batch amount', async () => { + prisma.honeyBatch.findUnique.mockResolvedValueOnce({ + id: 'h1', + amount: 5, + }); + + await expect( + service.create( + { + honeyBatchId: 'h1', + date: '2026-01-01', + jarSize: 500, + jarCount: 20, + bestBeforeDate: '2027-01-01', + lotNumber: 'L-1', + }, + 'user-1', + ), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('creates a bottling batch and reduces the honey batch amount transactionally', async () => { + prisma.honeyBatch.findUnique.mockResolvedValueOnce({ + id: 'h1', + amount: 25, + }); + tx.bottlingBatch.create.mockResolvedValueOnce({ + id: 'b1', + totalAmount: 25, + }); + prisma.bottlingBatch.findUnique.mockResolvedValueOnce({ + id: 'b1', + totalAmount: 25, + }); + + const result = await service.create( + { + honeyBatchId: 'h1', + date: '2026-01-01', + jarSize: 500, + jarCount: 50, + bestBeforeDate: '2027-01-01', + lotNumber: 'L-1', + }, + 'user-1', + ); + + expect(result).toEqual({ id: 'b1', totalAmount: 25 }); + expect(tx.honeyBatch.update).toHaveBeenCalledWith({ + where: { id: 'h1' }, + data: { amount: 0, status: 'BOTTLED' }, + }); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + entityType: 'BottlingBatch', + action: 'CREATE', + }), + ); + }); +}); diff --git a/ApiCore-Backend/src/bottling/bottling.service.ts b/ApiCore-Backend/src/bottling/bottling.service.ts new file mode 100644 index 0000000..7fb4721 --- /dev/null +++ b/ApiCore-Backend/src/bottling/bottling.service.ts @@ -0,0 +1,108 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { BottlingBatch } from '../../generated/prisma'; +import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto'; + +const bottlingBatchInclude = { + honeyBatch: true, + product: true, +} as const; + +export interface BottlingBatchFilter { + honeyBatchId?: string; +} + +@Injectable() +export class BottlingService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(filter: BottlingBatchFilter = {}) { + return this.prisma.bottlingBatch.findMany({ + where: { honeyBatchId: filter.honeyBatchId }, + include: bottlingBatchInclude, + orderBy: { date: 'desc' }, + }); + } + + async findById(id: string) { + const bottlingBatch = await this.prisma.bottlingBatch.findUnique({ + where: { id }, + include: bottlingBatchInclude, + }); + if (!bottlingBatch) { + throw new NotFoundException('Bottling batch not found'); + } + return bottlingBatch; + } + + async create(dto: CreateBottlingBatchDto, actingUserId: string) { + const honeyBatch = await this.prisma.honeyBatch.findUnique({ + where: { id: dto.honeyBatchId }, + }); + if (!honeyBatch) { + throw new NotFoundException('Honey batch not found'); + } + if (dto.productId) { + const product = await this.prisma.product.findUnique({ + where: { id: dto.productId }, + }); + if (!product) { + throw new NotFoundException('Product not found'); + } + } + + const totalAmount = (dto.jarSize * dto.jarCount) / 1000; + if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) { + throw new BadRequestException( + 'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge', + ); + } + + const bottlingBatch: BottlingBatch = await this.prisma.$transaction( + async (tx) => { + const created = await tx.bottlingBatch.create({ + data: { + honeyBatchId: dto.honeyBatchId, + productId: dto.productId, + date: new Date(dto.date), + jarSize: dto.jarSize, + jarCount: dto.jarCount, + totalAmount, + bestBeforeDate: new Date(dto.bestBeforeDate), + lotNumber: dto.lotNumber, + userId: actingUserId, + }, + }); + await tx.honeyBatch.update({ + where: { id: dto.honeyBatchId }, + data: { + amount: + honeyBatch.amount !== null + ? honeyBatch.amount - totalAmount + : null, + status: 'BOTTLED', + }, + }); + return created; + }, + ); + + await this.auditLog.record({ + entityType: 'BottlingBatch', + entityId: bottlingBatch.id, + action: 'CREATE', + userId: actingUserId, + afterData: bottlingBatch, + }); + + return this.findById(bottlingBatch.id); + } +} diff --git a/ApiCore-Backend/src/bottling/dto/create-bottling-batch.dto.ts b/ApiCore-Backend/src/bottling/dto/create-bottling-batch.dto.ts new file mode 100644 index 0000000..d7d9f74 --- /dev/null +++ b/ApiCore-Backend/src/bottling/dto/create-bottling-batch.dto.ts @@ -0,0 +1,42 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsDateString, + IsInt, + IsNumber, + IsOptional, + IsPositive, + IsString, +} from 'class-validator'; + +export class CreateBottlingBatchDto { + @ApiProperty() + @IsString() + honeyBatchId!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + productId?: string; + + @ApiProperty() + @IsDateString() + date!: string; + + @ApiProperty({ description: 'Glasgröße in Gramm' }) + @IsNumber() + @IsPositive() + jarSize!: number; + + @ApiProperty() + @IsInt() + @IsPositive() + jarCount!: number; + + @ApiProperty() + @IsDateString() + bestBeforeDate!: string; + + @ApiProperty() + @IsString() + lotNumber!: string; +} diff --git a/ApiCore-Backend/src/products/dto/create-product.dto.ts b/ApiCore-Backend/src/products/dto/create-product.dto.ts new file mode 100644 index 0000000..bfd5394 --- /dev/null +++ b/ApiCore-Backend/src/products/dto/create-product.dto.ts @@ -0,0 +1,33 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class CreateProductDto { + @ApiProperty() + @IsString() + name!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + description?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + sku?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + packagingSize?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + weight?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + salesUnit?: string; +} diff --git a/ApiCore-Backend/src/products/dto/update-product.dto.ts b/ApiCore-Backend/src/products/dto/update-product.dto.ts new file mode 100644 index 0000000..549700b --- /dev/null +++ b/ApiCore-Backend/src/products/dto/update-product.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { ProductStatus } from '../../../generated/prisma'; +import { CreateProductDto } from './create-product.dto'; + +export class UpdateProductDto extends PartialType(CreateProductDto) { + @ApiPropertyOptional({ enum: ProductStatus }) + @IsOptional() + @IsEnum(ProductStatus) + status?: ProductStatus; +} diff --git a/ApiCore-Backend/src/products/products.controller.ts b/ApiCore-Backend/src/products/products.controller.ts new file mode 100644 index 0000000..8353ee5 --- /dev/null +++ b/ApiCore-Backend/src/products/products.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { ProductStatus, 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 { CreateProductDto } from './dto/create-product.dto'; +import { UpdateProductDto } from './dto/update-product.dto'; +import { ProductsService } from './products.service'; + +@ApiTags('products') +@ApiBearerAuth() +@Controller('products') +export class ProductsController { + constructor(private readonly productsService: ProductsService) {} + + @Get() + findAll(@Query('status') status?: ProductStatus) { + return this.productsService.findAll({ status }); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.productsService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateProductDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.productsService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateProductDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.productsService.update(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/products/products.module.ts b/ApiCore-Backend/src/products/products.module.ts new file mode 100644 index 0000000..883c79f --- /dev/null +++ b/ApiCore-Backend/src/products/products.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { ProductsController } from './products.controller'; +import { ProductsService } from './products.service'; + +@Module({ + controllers: [ProductsController], + providers: [ProductsService], + exports: [ProductsService], +}) +export class ProductsModule {} diff --git a/ApiCore-Backend/src/products/products.service.spec.ts b/ApiCore-Backend/src/products/products.service.spec.ts new file mode 100644 index 0000000..7243a25 --- /dev/null +++ b/ApiCore-Backend/src/products/products.service.spec.ts @@ -0,0 +1,74 @@ +import { NotFoundException } from '@nestjs/common'; +import { ProductsService } from './products.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('ProductsService', () => { + let service: ProductsService; + let prisma: { + product: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + beforeEach(() => { + prisma = { + product: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + auditLog = { record: jest.fn() }; + service = new ProductsService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('creates a product and records an audit log entry', async () => { + const created = { id: 'p1', name: 'Sommerhonig' }; + prisma.product.create.mockResolvedValueOnce(created); + + const result = await service.create({ name: 'Sommerhonig' }, 'user-1'); + + expect(result).toEqual(created); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'Product', action: 'CREATE' }), + ); + }); + + it('throws NotFoundException when the product does not exist', async () => { + prisma.product.findUnique.mockResolvedValueOnce(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('updates a product and records before/after audit data', async () => { + const before = { id: 'p1', name: 'Sommerhonig' }; + const after = { id: 'p1', name: 'Sommerhonig 500g' }; + prisma.product.findUnique.mockResolvedValueOnce(before); + prisma.product.update.mockResolvedValueOnce(after); + + const result = await service.update( + 'p1', + { name: 'Sommerhonig 500g' }, + 'user-1', + ); + + expect(result).toEqual(after); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'UPDATE', + beforeData: before, + afterData: after, + }), + ); + }); +}); diff --git a/ApiCore-Backend/src/products/products.service.ts b/ApiCore-Backend/src/products/products.service.ts new file mode 100644 index 0000000..6f315eb --- /dev/null +++ b/ApiCore-Backend/src/products/products.service.ts @@ -0,0 +1,66 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Product, ProductStatus } from '../../generated/prisma'; +import { CreateProductDto } from './dto/create-product.dto'; +import { UpdateProductDto } from './dto/update-product.dto'; + +export interface ProductFilter { + status?: ProductStatus; +} + +@Injectable() +export class ProductsService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(filter: ProductFilter = {}): Promise { + return this.prisma.product.findMany({ + where: { status: filter.status }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + const product = await this.prisma.product.findUnique({ where: { id } }); + if (!product) { + throw new NotFoundException('Product not found'); + } + return product; + } + + async create(dto: CreateProductDto, actingUserId: string): Promise { + const product = await this.prisma.product.create({ data: { ...dto } }); + await this.auditLog.record({ + entityType: 'Product', + entityId: product.id, + action: 'CREATE', + userId: actingUserId, + afterData: product, + }); + return product; + } + + async update( + id: string, + dto: UpdateProductDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const product = await this.prisma.product.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'Product', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: product, + }); + return product; + } +} diff --git a/ApiCore-Backend/test/bottling.e2e-spec.ts b/ApiCore-Backend/test/bottling.e2e-spec.ts new file mode 100644 index 0000000..dff33bc --- /dev/null +++ b/ApiCore-Backend/test/bottling.e2e-spec.ts @@ -0,0 +1,137 @@ +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 { + amount: number | null; + status: string; +} + +interface BottlingBatchBody { + id: string; + totalAmount: number; + lotNumber: string; +} + +describe('Bottling (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `bottling-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let beekeeperToken: string; + let apiaryId: string; + let colonyId: string; + let harvestId: string; + let extractionId: string; + let honeyBatchId: string; + let bottlingBatchId: 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; + beekeeperToken = (await login(app, beekeeperEmail, 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 }, + }); + const honeyBatch = await prisma.honeyBatch.create({ + data: { batchNumber: `HC-${Date.now()}`, extractionId, amount: 25 }, + }); + honeyBatchId = honeyBatch.id; + }); + + afterAll(async () => { + if (bottlingBatchId) { + await prisma.bottlingBatch.deleteMany({ where: { id: bottlingBatchId } }); + } + 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: beekeeperId } }); + await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); + await prisma.user.deleteMany({ where: { id: beekeeperId } }); + await app.close(); + }); + + it('rejects a bottling amount exceeding the available honey batch amount', () => { + return request(app.getHttpServer()) + .post('/api/v1/bottling-batches') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + honeyBatchId, + date: new Date().toISOString(), + jarSize: 500, + jarCount: 1000, + bestBeforeDate: new Date().toISOString(), + lotNumber: `L-${Date.now()}`, + }) + .expect(400); + }); + + it('bottles a honey batch and reduces its remaining amount', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/bottling-batches') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + honeyBatchId, + date: new Date().toISOString(), + jarSize: 500, + jarCount: 50, + bestBeforeDate: new Date().toISOString(), + lotNumber: `L-${Date.now()}`, + }) + .expect(201); + const bottlingBatch = response.body as BottlingBatchBody; + bottlingBatchId = bottlingBatch.id; + expect(bottlingBatch.totalAmount).toBe(25); + + const honeyBatchResponse = await request(app.getHttpServer()) + .get(`/api/v1/honey-batches/${honeyBatchId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + const honeyBatch = honeyBatchResponse.body as HoneyBatchBody; + expect(honeyBatch.amount).toBe(0); + expect(honeyBatch.status).toBe('BOTTLED'); + }); +}); diff --git a/ApiCore-Backend/test/products.e2e-spec.ts b/ApiCore-Backend/test/products.e2e-spec.ts new file mode 100644 index 0000000..837cee3 --- /dev/null +++ b/ApiCore-Backend/test/products.e2e-spec.ts @@ -0,0 +1,102 @@ +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 ProductBody { + id: string; + name: string; + status: string; +} + +describe('Products (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `products-e2e-beekeeper-${Date.now()}@example.com`; + const staffEmail = `products-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let staffId: string; + let beekeeperToken: string; + let staffToken: string; + let productId: 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; + }); + + afterAll(async () => { + if (productId) { + await prisma.product.deleteMany({ where: { id: productId } }); + } + 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/products') + .set('Authorization', `Bearer ${staffToken}`) + .send({ name: `Sommerhonig ${Date.now()}` }) + .expect(403); + }); + + it('lets a BEEKEEPER create a product', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/products') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ name: `Sommerhonig ${Date.now()}`, salesUnit: 'Glas' }) + .expect(201); + const product = response.body as ProductBody; + productId = product.id; + expect(product.status).toBe('ACTIVE'); + }); + + it('lists products for any authenticated role', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/products') + .set('Authorization', `Bearer ${staffToken}`) + .expect(200); + const products = response.body as ProductBody[]; + expect(products.some((product) => product.id === productId)).toBe(true); + }); + + it('updates a product status', async () => { + const response = await request(app.getHttpServer()) + .patch(`/api/v1/products/${productId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ status: 'DISCONTINUED' }) + .expect(200); + const product = response.body as ProductBody; + expect(product.status).toBe('DISCONTINUED'); + }); +}); diff --git a/TASKS.md b/TASKS.md index 1c8d6e2..b8ad6a3 100644 --- a/TASKS.md +++ b/TASKS.md @@ -187,7 +187,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Backend: `BottlingModule` (`BottlingBatch`), `ProductsModule` (`Product`); Abfüllung reduziert `HoneyBatch.amount` transaktional - Endpunkte: `GET/POST /api/v1/bottling-batches`, `GET /api/v1/bottling-batches/:id`, `GET/POST /api/v1/products`, `GET/PATCH /api/v1/products/:id` - Frontend: `/bottling/new`, `/bottling/:id`, `/products`, `/products/new` -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 24. Inventar