diff --git a/ApiCore-Backend/src/production-batches/production-batches.service.ts b/ApiCore-Backend/src/production-batches/production-batches.service.ts index 2aa9941..492a240 100644 --- a/ApiCore-Backend/src/production-batches/production-batches.service.ts +++ b/ApiCore-Backend/src/production-batches/production-batches.service.ts @@ -1,9 +1,11 @@ import { BadRequestException, ConflictException, + ForbiddenException, Injectable, NotFoundException, } from '@nestjs/common'; +import { PermissionResolverService } from '../auth/permissions/permission-resolver.service'; import { PrismaService } from '../prisma/prisma.service'; import { AuditLogService } from '../audit/audit-log.service'; import { StockService } from '../stock/stock.service'; @@ -38,6 +40,7 @@ export class ProductionBatchesService { private readonly prisma: PrismaService, private readonly auditLog: AuditLogService, private readonly stock: StockService, + private readonly permissions: PermissionResolverService, ) {} async findAll(filter: ProductionBatchFilter = {}) { @@ -66,6 +69,16 @@ export class ProductionBatchesService { } async create(dto: CreateProductionBatchDto, actingUserId: string) { + // Consuming raw material is a withdrawal from someone else's stock: + // production-batch:create alone must not be enough to empty a wax lot. + if (dto.inputs && dto.inputs.length > 0) { + const granted = await this.permissions.getPermissions(actingUserId); + if (!granted.has('raw-material:withdraw')) { + throw new ForbiddenException( + 'Rohstoffe verbrauchen erfordert die Berechtigung "Rohstoffe: Entnehmen und Inventur"', + ); + } + } const product = await this.prisma.product.findUnique({ where: { id: dto.productId }, }); @@ -124,20 +137,29 @@ export class ProductionBatchesService { actingUserId: string, ) { const before = await this.findById(id); - await this.prisma.productionBatch.update({ - where: { id }, - data: { - lotNumber: dto.lotNumber, - date: dto.date ? new Date(dto.date) : undefined, - quantity: dto.quantity, - bestBeforeDate: - dto.bestBeforeDate === undefined - ? undefined - : dto.bestBeforeDate === null - ? null - : new Date(dto.bestBeforeDate), - notes: dto.notes, - }, + await this.prisma.$transaction(async (tx) => { + if (dto.quantity !== undefined && dto.quantity !== before.quantity) { + await this.stock.assertInitialCovers( + tx, + { kind: 'production', id }, + dto.quantity, + ); + } + await tx.productionBatch.update({ + where: { id }, + data: { + lotNumber: dto.lotNumber, + date: dto.date ? new Date(dto.date) : undefined, + quantity: dto.quantity, + bestBeforeDate: + dto.bestBeforeDate === undefined + ? undefined + : dto.bestBeforeDate === null + ? null + : new Date(dto.bestBeforeDate), + notes: dto.notes, + }, + }); }); const after = await this.findById(id); await this.auditLog.record({ diff --git a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts index 436e516..15fa0a9 100644 --- a/ApiCore-Backend/src/raw-materials/raw-materials.service.ts +++ b/ApiCore-Backend/src/raw-materials/raw-materials.service.ts @@ -99,16 +99,25 @@ export class RawMaterialsService { async update(id: string, dto: UpdateRawMaterialLotDto, actingUserId: string) { const before = await this.findById(id); - await this.prisma.rawMaterialLot.update({ - where: { id }, - data: { - lotNumber: dto.lotNumber, - date: dto.date ? new Date(dto.date) : undefined, - quantity: dto.quantity, - apiaryId: dto.apiaryId, - harvestId: dto.harvestId, - notes: dto.notes, - }, + await this.prisma.$transaction(async (tx) => { + if (dto.quantity !== undefined && dto.quantity !== before.quantity) { + await this.stock.assertInitialCovers( + tx, + { kind: 'raw', id }, + dto.quantity, + ); + } + await tx.rawMaterialLot.update({ + where: { id }, + data: { + lotNumber: dto.lotNumber, + date: dto.date ? new Date(dto.date) : undefined, + quantity: dto.quantity, + apiaryId: dto.apiaryId, + harvestId: dto.harvestId, + notes: dto.notes, + }, + }); }); const after = await this.findById(id); await this.auditLog.record({ diff --git a/ApiCore-Backend/src/stock/stock.service.ts b/ApiCore-Backend/src/stock/stock.service.ts index 645a689..2676a49 100644 --- a/ApiCore-Backend/src/stock/stock.service.ts +++ b/ApiCore-Backend/src/stock/stock.service.ts @@ -304,6 +304,28 @@ export class StockService { } } + /** + * Guards a corrected initial quantity (produced pieces, harvested grams): + * it may not drop below what was already sold, withdrawn or consumed - + * that would turn the remaining stock negative without anyone booking a + * movement. Locks the stock row; call inside the transaction that writes + * the new quantity. A real shortfall belongs in an inventory correction. + */ + async assertInitialCovers( + tx: Tx, + target: StockTarget, + newInitial: number, + ): Promise { + await this.lock(tx, target); + const sums = await this.sumMovements(tx, target.kind, [target.id]); + const moved = sums.get(target.id) ?? 0; + if (newInitial + moved < 0) { + throw new BadRequestException( + `Menge ${newInitial} ist kleiner als bereits verbucht (${-moved}) - Fehlmengen bitte über "Inventur korrigieren" buchen`, + ); + } + } + /** * Gives the raw material of a production batch back - only for a batch * deleted as a typo. The give-back is a movement of its own, so the raw diff --git a/ApiCore-Backend/test/raw-materials.e2e-spec.ts b/ApiCore-Backend/test/raw-materials.e2e-spec.ts index 2f05d9c..9a034c0 100644 --- a/ApiCore-Backend/test/raw-materials.e2e-spec.ts +++ b/ApiCore-Backend/test/raw-materials.e2e-spec.ts @@ -176,6 +176,61 @@ describe('Raw materials and production (e2e)', () => { expect((await stockOf(propolisId)).summary.remaining).toBe(200); }); + it('refuses to correct a raw lot below what was already used or withdrawn', async () => { + // wax: 1000 g harvested, 100 g withdrawn -> at least 100 g must remain booked + await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`)) + .send({ quantity: 50 }) + .expect(400); + await auth(request(server()).patch(`/api/v1/raw-material-lots/${waxId}`)) + .send({ quantity: 950 }) + .expect(200); + expect((await stockOf(waxId)).summary.remaining).toBe(850); + }); + + it('needs the raw material withdraw permission to consume raw material', async () => { + const role = await prisma.role.create({ + data: { + key: `PRODUCER_ONLY_${suffix}`, + name: `Nur Herstellung ${suffix}`, + permissions: { + create: ( + await prisma.permission.findMany({ + where: { + key: { + in: ['production-batch:read', 'production-batch:create'], + }, + }, + }) + ).map((permission) => ({ permissionId: permission.id })), + }, + }, + }); + await prisma.user.create({ + data: { + email: `raw-producer-${suffix}@example.com`, + passwordHash: await bcrypt.hash(password, 12), + name: 'Producer', + roleId: role.id, + }, + }); + const producerToken = ( + await login(app, `raw-producer-${suffix}@example.com`, password) + ).accessToken; + + await request(server()) + .post('/api/v1/production-batches') + .set('Authorization', `Bearer ${producerToken}`) + .send({ + productId, + lotNumber: `S3-${suffix}`, + date: '2026-06-12', + quantity: 5, + inputs: [{ rawMaterialLotId: propolisId, quantity: 10 }], + }) + .expect(403); + expect((await stockOf(propolisId)).summary.remaining).toBe(200); + }); + it('keeps a raw lot with movements as history', async () => { await auth( request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),