A bottling batch could only be corrected in four of its seven fields after creation: honey batch, jar size and jar count were frozen at creation time, an assigned product could not be cleared again, and there was no way to remove a record entered by mistake at all. A mistyped jar count is a data-entry error, not a fact of production, so it has to stay correctable. Backend: - UpdateBottlingBatchDto now covers every field. `productId: null` explicitly clears the product assignment; omitting it leaves it alone. - BottlingService.update rebalances the affected honey batch amounts: the previously bottled amount is given back before the corrected amount is taken again, so a correction never double-charges the source batch. Moving a batch to a different honey batch credits the old one and debits the new one in the same transaction. - New DELETE /bottling-batches/:id gives the bottled amount back to the honey batch and keeps the full record in the audit log (action DELETE, beforeData). The honey batch status is deliberately left untouched - the status it held before being bottled is not recoverable here. - New permission bottling:delete, checked server-side. The catalog sync grants it to system roles on the next boot. Frontend: - The edit dialog exposes all fields, offers "Kein Produkt" to clear the assignment, keeps a discontinued product selectable so opening the dialog cannot silently drop it, and shows backend errors inline. - The form is mounted only while the dialog is open, so a cancelled edit never leaks its values into the next one. - New delete button behind a confirmation that names the amount credited back and warns that the public origin page for the lot number stops resolving. - Edit and delete are additionally hidden without the matching permission. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
281 lines
8.8 KiB
TypeScript
281 lines
8.8 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { PrismaService } from '../prisma/prisma.service';
|
|
import { AuditLogService } from '../audit/audit-log.service';
|
|
import { WebhooksService } from '../webhooks/webhooks.service';
|
|
import { AutomationService } from '../automation/automation.service';
|
|
import { BottlingBatch } from '../../generated/prisma';
|
|
import { CreateBottlingBatchDto } from './dto/create-bottling-batch.dto';
|
|
import { UpdateBottlingBatchDto } from './dto/update-bottling-batch.dto';
|
|
|
|
const bottlingBatchInclude = {
|
|
honeyBatch: true,
|
|
product: true,
|
|
} as const;
|
|
|
|
export interface BottlingBatchFilter {
|
|
honeyBatchId?: string;
|
|
}
|
|
|
|
/**
|
|
* Amounts are kept in kilograms with gram precision. Rounding on every write
|
|
* keeps a honey batch's remaining amount from drifting when a bottling batch
|
|
* is corrected or removed and its amount is given back.
|
|
*/
|
|
function roundKg(amount: number): number {
|
|
return Math.round(amount * 1000) / 1000;
|
|
}
|
|
|
|
@Injectable()
|
|
export class BottlingService {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly auditLog: AuditLogService,
|
|
private readonly webhooks: WebhooksService,
|
|
private readonly automation: AutomationService,
|
|
) {}
|
|
|
|
async findAll(filter: BottlingBatchFilter = {}) {
|
|
return this.prisma.bottlingBatch.findMany({
|
|
where: { honeyBatchId: filter.honeyBatchId },
|
|
include: bottlingBatchInclude,
|
|
orderBy: { date: 'desc' },
|
|
});
|
|
}
|
|
|
|
async findById(id: string) {
|
|
const bottlingBatch = await this.prisma.bottlingBatch.findUnique({
|
|
where: { id },
|
|
include: bottlingBatchInclude,
|
|
});
|
|
if (!bottlingBatch) {
|
|
throw new NotFoundException('Bottling batch not found');
|
|
}
|
|
return bottlingBatch;
|
|
}
|
|
|
|
async create(dto: CreateBottlingBatchDto, actingUserId: string) {
|
|
const honeyBatch = await this.prisma.honeyBatch.findUnique({
|
|
where: { id: dto.honeyBatchId },
|
|
});
|
|
if (!honeyBatch) {
|
|
throw new NotFoundException('Honey batch not found');
|
|
}
|
|
if (dto.productId) {
|
|
const product = await this.prisma.product.findUnique({
|
|
where: { id: dto.productId },
|
|
});
|
|
if (!product) {
|
|
throw new NotFoundException('Product not found');
|
|
}
|
|
}
|
|
|
|
const totalAmount = (dto.jarSize * dto.jarCount) / 1000;
|
|
if (honeyBatch.amount !== null && totalAmount > honeyBatch.amount) {
|
|
throw new BadRequestException(
|
|
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
|
);
|
|
}
|
|
|
|
const lotNumber =
|
|
dto.lotNumber ??
|
|
(await this.automation.generateNumber('BOTTLING_LOT_NUMBER_FORMAT'));
|
|
if (!lotNumber) {
|
|
throw new BadRequestException(
|
|
'Los-/Chargennummer erforderlich (oder Automatisierung "Los-/Chargennummer-Format" aktivieren)',
|
|
);
|
|
}
|
|
|
|
const bottlingBatch: BottlingBatch = await this.prisma.$transaction(
|
|
async (tx) => {
|
|
const created = await tx.bottlingBatch.create({
|
|
data: {
|
|
honeyBatchId: dto.honeyBatchId,
|
|
productId: dto.productId,
|
|
date: new Date(dto.date),
|
|
jarSize: dto.jarSize,
|
|
jarCount: dto.jarCount,
|
|
totalAmount,
|
|
bestBeforeDate: new Date(dto.bestBeforeDate),
|
|
lotNumber,
|
|
userId: actingUserId,
|
|
},
|
|
});
|
|
await tx.honeyBatch.update({
|
|
where: { id: dto.honeyBatchId },
|
|
data: {
|
|
amount:
|
|
honeyBatch.amount !== null
|
|
? honeyBatch.amount - totalAmount
|
|
: null,
|
|
status: 'BOTTLED',
|
|
},
|
|
});
|
|
return created;
|
|
},
|
|
);
|
|
|
|
await this.auditLog.record({
|
|
entityType: 'BottlingBatch',
|
|
entityId: bottlingBatch.id,
|
|
action: 'CREATE',
|
|
userId: actingUserId,
|
|
afterData: bottlingBatch,
|
|
});
|
|
|
|
const created = await this.findById(bottlingBatch.id);
|
|
await this.webhooks.dispatch('batch.updated', created.honeyBatch);
|
|
return created;
|
|
}
|
|
|
|
/**
|
|
* Corrects any field of an existing bottling batch. Changing the honey
|
|
* batch, the jar size or the jar count also rebalances the remaining amount
|
|
* of the affected honey batch(es): the previously bottled amount is given
|
|
* back before the corrected amount is taken again, so a correction never
|
|
* double-charges the source batch.
|
|
*/
|
|
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
|
|
const before = await this.findById(id);
|
|
|
|
if (dto.productId) {
|
|
const product = await this.prisma.product.findUnique({
|
|
where: { id: dto.productId },
|
|
});
|
|
if (!product) {
|
|
throw new NotFoundException('Product not found');
|
|
}
|
|
}
|
|
|
|
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
|
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
|
const jarSize = dto.jarSize ?? before.jarSize;
|
|
const jarCount = dto.jarCount ?? before.jarCount;
|
|
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
|
|
|
const targetHoneyBatch = movedToOtherHoneyBatch
|
|
? await this.prisma.honeyBatch.findUnique({ where: { id: honeyBatchId } })
|
|
: before.honeyBatch;
|
|
if (!targetHoneyBatch) {
|
|
throw new NotFoundException('Honey batch not found');
|
|
}
|
|
|
|
// Capacity on the target batch, with this bottling batch's current amount
|
|
// released first when it already sits on that same batch.
|
|
const availableOnTarget =
|
|
targetHoneyBatch.amount === null
|
|
? null
|
|
: roundKg(
|
|
targetHoneyBatch.amount +
|
|
(movedToOtherHoneyBatch ? 0 : before.totalAmount),
|
|
);
|
|
if (availableOnTarget !== null && totalAmount > availableOnTarget) {
|
|
throw new BadRequestException(
|
|
'Abfüllmenge übersteigt die verfügbare Menge der Honigcharge',
|
|
);
|
|
}
|
|
|
|
// A duplicate lotNumber surfaces as Prisma P2002 and is turned into a
|
|
// 409 with a field-specific message by AllExceptionsFilter.
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.bottlingBatch.update({
|
|
where: { id },
|
|
data: {
|
|
honeyBatchId,
|
|
productId: dto.productId === undefined ? undefined : dto.productId,
|
|
date: dto.date ? new Date(dto.date) : undefined,
|
|
jarSize,
|
|
jarCount,
|
|
totalAmount,
|
|
bestBeforeDate: dto.bestBeforeDate
|
|
? new Date(dto.bestBeforeDate)
|
|
: undefined,
|
|
lotNumber: dto.lotNumber,
|
|
},
|
|
});
|
|
|
|
if (movedToOtherHoneyBatch) {
|
|
await tx.honeyBatch.update({
|
|
where: { id: before.honeyBatchId },
|
|
data: {
|
|
amount:
|
|
before.honeyBatch.amount === null
|
|
? null
|
|
: roundKg(before.honeyBatch.amount + before.totalAmount),
|
|
},
|
|
});
|
|
await tx.honeyBatch.update({
|
|
where: { id: honeyBatchId },
|
|
data: {
|
|
amount:
|
|
availableOnTarget === null
|
|
? null
|
|
: roundKg(availableOnTarget - totalAmount),
|
|
status: 'BOTTLED',
|
|
},
|
|
});
|
|
} else if (totalAmount !== before.totalAmount) {
|
|
await tx.honeyBatch.update({
|
|
where: { id: honeyBatchId },
|
|
data: {
|
|
amount:
|
|
availableOnTarget === null
|
|
? null
|
|
: roundKg(availableOnTarget - totalAmount),
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
const bottlingBatch = await this.findById(id);
|
|
await this.auditLog.record({
|
|
entityType: 'BottlingBatch',
|
|
entityId: id,
|
|
action: 'UPDATE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
afterData: bottlingBatch,
|
|
});
|
|
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
|
|
return bottlingBatch;
|
|
}
|
|
|
|
/**
|
|
* Removes a bottling batch that was entered by mistake and gives its amount
|
|
* back to the honey batch. The full record is kept in the audit log
|
|
* (action DELETE, beforeData), so the history survives the row.
|
|
*
|
|
* The honey batch's status is deliberately left untouched: the status it
|
|
* held before being bottled is not recoverable here, and guessing it would
|
|
* write a fact that never happened.
|
|
*/
|
|
async remove(id: string, actingUserId: string) {
|
|
const before = await this.findById(id);
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
await tx.bottlingBatch.delete({ where: { id } });
|
|
if (before.honeyBatch.amount !== null) {
|
|
await tx.honeyBatch.update({
|
|
where: { id: before.honeyBatchId },
|
|
data: {
|
|
amount: roundKg(before.honeyBatch.amount + before.totalAmount),
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
await this.auditLog.record({
|
|
entityType: 'BottlingBatch',
|
|
entityId: id,
|
|
action: 'DELETE',
|
|
userId: actingUserId,
|
|
beforeData: before,
|
|
});
|
|
await this.webhooks.dispatch('batch.updated', before.honeyBatch);
|
|
return { id };
|
|
}
|
|
}
|