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
@@ -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}`),