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:
1 parent
e52a6057a2
commit
f9899d5621
4 files changed
+110
-2
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,7 +137,15 @@ export class ProductionBatchesService {
|
||||
actingUserId: string,
|
||||
) {
|
||||
const before = await this.findById(id);
|
||||
await this.prisma.productionBatch.update({
|
||||
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,
|
||||
@@ -139,6 +160,7 @@ export class ProductionBatchesService {
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
});
|
||||
const after = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'ProductionBatch',
|
||||
|
||||
@@ -99,7 +99,15 @@ export class RawMaterialsService {
|
||||
|
||||
async update(id: string, dto: UpdateRawMaterialLotDto, actingUserId: string) {
|
||||
const before = await this.findById(id);
|
||||
await this.prisma.rawMaterialLot.update({
|
||||
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,
|
||||
@@ -110,6 +118,7 @@ export class RawMaterialsService {
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
});
|
||||
const after = await this.findById(id);
|
||||
await this.auditLog.record({
|
||||
entityType: 'RawMaterialLot',
|
||||
|
||||
@@ -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}`),
|
||||
|
||||
Reference in new issue
Block a user