diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 8a2944d..2aee6be 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -17,6 +17,7 @@ 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 { StorageModule } from './storage/storage.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -39,6 +40,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; HarvestsModule, ExtractionsModule, HoneyBatchesModule, + StorageModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/storage/dto/create-stock-item.dto.ts b/ApiCore-Backend/src/storage/dto/create-stock-item.dto.ts new file mode 100644 index 0000000..f64f1ed --- /dev/null +++ b/ApiCore-Backend/src/storage/dto/create-stock-item.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; +import { StockCategory } from '../../../generated/prisma'; + +export class CreateStockItemDto { + @ApiProperty() + @IsString() + name!: string; + + @ApiProperty({ enum: StockCategory }) + @IsEnum(StockCategory) + category!: StockCategory; + + @ApiProperty() + @IsString() + unit!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + quantity?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + minThreshold?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/storage/dto/create-stock-movement.dto.ts b/ApiCore-Backend/src/storage/dto/create-stock-movement.dto.ts new file mode 100644 index 0000000..3974133 --- /dev/null +++ b/ApiCore-Backend/src/storage/dto/create-stock-movement.dto.ts @@ -0,0 +1,28 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator'; +import { StockMovementType } from '../../../generated/prisma'; + +export class CreateStockMovementDto { + @ApiProperty({ enum: StockMovementType }) + @IsEnum(StockMovementType) + type!: StockMovementType; + + @ApiProperty() + @IsNumber() + quantity!: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + relatedType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + relatedId?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + notes?: string; +} diff --git a/ApiCore-Backend/src/storage/dto/create-storage-container.dto.ts b/ApiCore-Backend/src/storage/dto/create-storage-container.dto.ts new file mode 100644 index 0000000..c6259e8 --- /dev/null +++ b/ApiCore-Backend/src/storage/dto/create-storage-container.dto.ts @@ -0,0 +1,23 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsNumber, IsOptional, IsString } from 'class-validator'; + +export class CreateStorageContainerDto { + @ApiProperty() + @IsString() + code!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + containerType?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsNumber() + capacity?: number; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + location?: string; +} diff --git a/ApiCore-Backend/src/storage/storage.controller.ts b/ApiCore-Backend/src/storage/storage.controller.ts new file mode 100644 index 0000000..5a2c849 --- /dev/null +++ b/ApiCore-Backend/src/storage/storage.controller.ts @@ -0,0 +1,60 @@ +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 { CreateStorageContainerDto } from './dto/create-storage-container.dto'; +import { CreateStockItemDto } from './dto/create-stock-item.dto'; +import { CreateStockMovementDto } from './dto/create-stock-movement.dto'; +import { StorageService } from './storage.service'; + +@ApiTags('storage') +@ApiBearerAuth() +@Controller() +export class StorageController { + constructor(private readonly storageService: StorageService) {} + + @Get('storage-containers') + findAllContainers() { + return this.storageService.findAllContainers(); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post('storage-containers') + createContainer( + @Body() dto: CreateStorageContainerDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.storageService.createContainer(dto, actor.id); + } + + @Get('stock-items') + findAllStockItems() { + return this.storageService.findAllStockItems(); + } + + @Get('stock-items/:id') + findStockItem(@Param('id') id: string) { + return this.storageService.findStockItemById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post('stock-items') + createStockItem( + @Body() dto: CreateStockItemDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.storageService.createStockItem(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post('stock-items/:id/movements') + addMovement( + @Param('id') id: string, + @Body() dto: CreateStockMovementDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.storageService.addMovement(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/storage/storage.module.ts b/ApiCore-Backend/src/storage/storage.module.ts new file mode 100644 index 0000000..2ba2abd --- /dev/null +++ b/ApiCore-Backend/src/storage/storage.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { StorageController } from './storage.controller'; +import { StorageService } from './storage.service'; + +@Module({ + controllers: [StorageController], + providers: [StorageService], + exports: [StorageService], +}) +export class StorageModule {} diff --git a/ApiCore-Backend/src/storage/storage.service.spec.ts b/ApiCore-Backend/src/storage/storage.service.spec.ts new file mode 100644 index 0000000..cb93a02 --- /dev/null +++ b/ApiCore-Backend/src/storage/storage.service.spec.ts @@ -0,0 +1,95 @@ +import { NotFoundException } from '@nestjs/common'; +import { StorageService } from './storage.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('StorageService', () => { + let service: StorageService; + let prisma: { + storageContainer: { findMany: jest.Mock; create: jest.Mock }; + stockItem: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + }; + $transaction: jest.Mock; + }; + let auditLog: { record: jest.Mock }; + let tx: { + stockMovement: { create: jest.Mock }; + stockItem: { update: jest.Mock }; + }; + + const stockItem = { id: 'item-1', name: 'Zuckerlösung', quantity: 10 }; + const movement = { + id: 'movement-1', + stockItemId: 'item-1', + type: 'IN', + quantity: 5, + }; + + beforeEach(() => { + tx = { + stockMovement: { create: jest.fn().mockResolvedValue(movement) }, + stockItem: { update: jest.fn() }, + }; + prisma = { + storageContainer: { findMany: jest.fn(), create: jest.fn() }, + stockItem: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + }, + $transaction: jest.fn((callback: (txArg: typeof tx) => unknown) => + callback(tx), + ), + }; + auditLog = { record: jest.fn() }; + service = new StorageService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('findStockItemById throws NotFoundException when missing', async () => { + prisma.stockItem.findUnique.mockResolvedValue(null); + await expect(service.findStockItemById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('addMovement increments quantity for an IN movement', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + + await service.addMovement('item-1', { type: 'IN', quantity: 5 }, 'user-1'); + + const [[updateArg]] = tx.stockItem.update.mock.calls as [ + [{ where: { id: string }; data: { quantity: { increment: number } } }], + ]; + expect(updateArg.where.id).toBe('item-1'); + expect(updateArg.data.quantity.increment).toBe(5); + }); + + it('addMovement decrements quantity for an OUT movement', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + + await service.addMovement('item-1', { type: 'OUT', quantity: 3 }, 'user-1'); + + const [[updateArg]] = tx.stockItem.update.mock.calls as [ + [{ data: { quantity: { increment: number } } }], + ]; + expect(updateArg.data.quantity.increment).toBe(-3); + }); + + it('addMovement leaves quantity untouched for a TRANSFER movement', async () => { + prisma.stockItem.findUnique.mockResolvedValue(stockItem); + + await service.addMovement( + 'item-1', + { type: 'TRANSFER', quantity: 3 }, + 'user-1', + ); + + expect(tx.stockItem.update).not.toHaveBeenCalled(); + }); +}); diff --git a/ApiCore-Backend/src/storage/storage.service.ts b/ApiCore-Backend/src/storage/storage.service.ts new file mode 100644 index 0000000..4a625e4 --- /dev/null +++ b/ApiCore-Backend/src/storage/storage.service.ts @@ -0,0 +1,101 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { StockItem, StorageContainer } from '../../generated/prisma'; +import { CreateStorageContainerDto } from './dto/create-storage-container.dto'; +import { CreateStockItemDto } from './dto/create-stock-item.dto'; +import { CreateStockMovementDto } from './dto/create-stock-movement.dto'; + +@Injectable() +export class StorageService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAllContainers(): Promise { + return this.prisma.storageContainer.findMany({ orderBy: { code: 'asc' } }); + } + + async createContainer( + dto: CreateStorageContainerDto, + actingUserId: string, + ): Promise { + const container = await this.prisma.storageContainer.create({ + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'StorageContainer', + entityId: container.id, + action: 'CREATE', + userId: actingUserId, + afterData: container, + }); + return container; + } + + async findAllStockItems(): Promise { + return this.prisma.stockItem.findMany({ orderBy: { name: 'asc' } }); + } + + async findStockItemById(id: string) { + const stockItem = await this.prisma.stockItem.findUnique({ + where: { id }, + include: { movements: { orderBy: { date: 'desc' } } }, + }); + if (!stockItem) { + throw new NotFoundException('Stock item not found'); + } + return stockItem; + } + + async createStockItem( + dto: CreateStockItemDto, + actingUserId: string, + ): Promise { + const stockItem = await this.prisma.stockItem.create({ data: { ...dto } }); + await this.auditLog.record({ + entityType: 'StockItem', + entityId: stockItem.id, + action: 'CREATE', + userId: actingUserId, + afterData: stockItem, + }); + return stockItem; + } + + async addMovement( + id: string, + dto: CreateStockMovementDto, + actingUserId: string, + ) { + await this.findStockItemById(id); + const delta = + dto.type === 'IN' || dto.type === 'ADJUSTMENT' + ? dto.quantity + : dto.type === 'OUT' + ? -dto.quantity + : 0; + + const movement = await this.prisma.$transaction(async (tx) => { + const created = await tx.stockMovement.create({ + data: { ...dto, stockItemId: id, userId: actingUserId }, + }); + if (delta !== 0) { + await tx.stockItem.update({ + where: { id }, + data: { quantity: { increment: delta } }, + }); + } + return created; + }); + await this.auditLog.record({ + entityType: 'StockMovement', + entityId: movement.id, + action: 'CREATE', + userId: actingUserId, + afterData: movement, + }); + return this.findStockItemById(id); + } +} diff --git a/ApiCore-Backend/test/storage.e2e-spec.ts b/ApiCore-Backend/test/storage.e2e-spec.ts new file mode 100644 index 0000000..bee00d3 --- /dev/null +++ b/ApiCore-Backend/test/storage.e2e-spec.ts @@ -0,0 +1,108 @@ +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 StockItemBody { + id: string; + quantity: number; +} + +describe('Storage (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `storage-e2e-beekeeper-${Date.now()}@example.com`; + const staffEmail = `storage-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let staffId: string; + let beekeeperToken: string; + let staffToken: string; + let stockItemId: 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 (stockItemId) { + await prisma.stockMovement.deleteMany({ where: { stockItemId } }); + await prisma.stockItem.deleteMany({ where: { id: stockItemId } }); + } + 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/stock-items') + .set('Authorization', `Bearer ${staffToken}`) + .send({ name: 'Zuckerlösung', category: 'CONSUMABLE', unit: 'l' }) + .expect(403); + }); + + it('lets a BEEKEEPER user create a stock item', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/stock-items') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ + name: 'Zuckerlösung', + category: 'CONSUMABLE', + unit: 'l', + quantity: 10, + }) + .expect(201); + const stockItem = response.body as StockItemBody; + stockItemId = stockItem.id; + expect(stockItem.quantity).toBe(10); + }); + + it('increments quantity on an IN movement', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/stock-items/${stockItemId}/movements`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ type: 'IN', quantity: 5 }) + .expect(201); + const stockItem = response.body as StockItemBody; + expect(stockItem.quantity).toBe(15); + }); + + it('decrements quantity on an OUT movement', async () => { + const response = await request(app.getHttpServer()) + .post(`/api/v1/stock-items/${stockItemId}/movements`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ type: 'OUT', quantity: 4 }) + .expect(201); + const stockItem = response.body as StockItemBody; + expect(stockItem.quantity).toBe(11); + }); +}); diff --git a/TASKS.md b/TASKS.md index 4054618..b1744e8 100644 --- a/TASKS.md +++ b/TASKS.md @@ -106,7 +106,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo - Backend: `StorageModule` (StorageContainer, StockItem, StockMovement) - Endpunkte: `GET/POST /api/v1/storage-containers`, `GET/POST /api/v1/stock-items`, `POST /api/v1/stock-items/:id/movements` - Frontend: `/storage` -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 13. Dashboard