fix(stock): require raw-material permission to consume, guard quantity edits

Security review findings on the raw material commit:
- Creating a production batch with inputs books raw material off other
  lots; production-batch:create alone no longer suffices, the user also
  needs raw-material:withdraw (403 otherwise).
- Correcting the quantity of a raw lot or production batch below what was
  already consumed, sold or withdrawn is refused (row lock + check in the
  same transaction) instead of silently driving the remaining stock
  negative; shortfalls go through an inventory correction.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Opus 5.5 committed 2026-10-06 06:31:06 +02:00
1 parent e52a6057a2
commit f9899d5621
4 files changed
+132 -24

No files matched your search

@@ -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({
@@ -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({
@@ -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<void> {
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