Files
ApiCore/ApiCore-Backend/src/bottling/bottling.service.ts
T
Nick SzaunigandClaude Opus 5 2b15795a60 feat: make every field of a bottling batch correctable and deletable
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]>
2026-09-19 07:50:36 +02:00

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 };
}
}