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
+132
-24
No files matched your search
@@ -1,9 +1,11 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
import { StockService } from '../stock/stock.service';
|
import { StockService } from '../stock/stock.service';
|
||||||
@@ -38,6 +40,7 @@ export class ProductionBatchesService {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly auditLog: AuditLogService,
|
private readonly auditLog: AuditLogService,
|
||||||
private readonly stock: StockService,
|
private readonly stock: StockService,
|
||||||
|
private readonly permissions: PermissionResolverService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async findAll(filter: ProductionBatchFilter = {}) {
|
async findAll(filter: ProductionBatchFilter = {}) {
|
||||||
@@ -66,6 +69,16 @@ export class ProductionBatchesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(dto: CreateProductionBatchDto, actingUserId: string) {
|
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({
|
const product = await this.prisma.product.findUnique({
|
||||||
where: { id: dto.productId },
|
where: { id: dto.productId },
|
||||||
});
|
});
|
||||||
@@ -124,20 +137,29 @@ export class ProductionBatchesService {
|
|||||||
actingUserId: string,
|
actingUserId: string,
|
||||||
) {
|
) {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
await this.prisma.productionBatch.update({
|
await this.prisma.$transaction(async (tx) => {
|
||||||
where: { id },
|
if (dto.quantity !== undefined && dto.quantity !== before.quantity) {
|
||||||
data: {
|
await this.stock.assertInitialCovers(
|
||||||
lotNumber: dto.lotNumber,
|
tx,
|
||||||
date: dto.date ? new Date(dto.date) : undefined,
|
{ kind: 'production', id },
|
||||||
quantity: dto.quantity,
|
dto.quantity,
|
||||||
bestBeforeDate:
|
);
|
||||||
dto.bestBeforeDate === undefined
|
}
|
||||||
? undefined
|
await tx.productionBatch.update({
|
||||||
: dto.bestBeforeDate === null
|
where: { id },
|
||||||
? null
|
data: {
|
||||||
: new Date(dto.bestBeforeDate),
|
lotNumber: dto.lotNumber,
|
||||||
notes: dto.notes,
|
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);
|
const after = await this.findById(id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
|
|||||||
@@ -99,16 +99,25 @@ export class RawMaterialsService {
|
|||||||
|
|
||||||
async update(id: string, dto: UpdateRawMaterialLotDto, actingUserId: string) {
|
async update(id: string, dto: UpdateRawMaterialLotDto, actingUserId: string) {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
await this.prisma.rawMaterialLot.update({
|
await this.prisma.$transaction(async (tx) => {
|
||||||
where: { id },
|
if (dto.quantity !== undefined && dto.quantity !== before.quantity) {
|
||||||
data: {
|
await this.stock.assertInitialCovers(
|
||||||
lotNumber: dto.lotNumber,
|
tx,
|
||||||
date: dto.date ? new Date(dto.date) : undefined,
|
{ kind: 'raw', id },
|
||||||
quantity: dto.quantity,
|
dto.quantity,
|
||||||
apiaryId: dto.apiaryId,
|
);
|
||||||
harvestId: dto.harvestId,
|
}
|
||||||
notes: dto.notes,
|
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);
|
const after = await this.findById(id);
|
||||||
await this.auditLog.record({
|
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
|
* 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
|
* 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);
|
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 () => {
|
it('keeps a raw lot with movements as history', async () => {
|
||||||
await auth(
|
await auth(
|
||||||
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
|
request(server()).delete(`/api/v1/raw-material-lots/${waxId}`),
|
||||||
|
|||||||
Reference in new issue
Block a user