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 {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user