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]>
This commit is contained in:
Nick SzaunigandClaude Opus 5 committed 2026-09-19 07:50:36 +02:00
1 parent 961ca04a40
commit 2b15795a60
10 files changed
+632 -109

No files matched your search

@@ -20,13 +20,18 @@ describe('BottlingService', () => {
};
let auditLog: { record: jest.Mock };
let tx: {
bottlingBatch: { create: jest.Mock };
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
honeyBatch: { update: jest.Mock };
};
let webhooks: { dispatch: jest.Mock };
beforeEach(() => {
tx = {
bottlingBatch: { create: jest.fn() },
bottlingBatch: {
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
},
honeyBatch: { update: jest.fn() },
};
prisma = {
@@ -43,7 +48,7 @@ describe('BottlingService', () => {
),
};
auditLog = { record: jest.fn() };
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
service = new BottlingService(
prisma as unknown as PrismaService,
@@ -131,12 +136,19 @@ describe('BottlingService', () => {
});
it('update writes an audit log entry with before and after data', async () => {
const before = { id: 'b1', lotNumber: 'L-1' };
const updated = { id: 'b1', lotNumber: 'L-2' };
const before = {
id: 'b1',
lotNumber: 'L-1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
const updated = { ...before, lotNumber: 'L-2' };
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce(updated);
prisma.bottlingBatch.update.mockResolvedValue(updated);
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
@@ -160,4 +172,136 @@ describe('BottlingService', () => {
service.update('b1', { productId: 'missing' }, 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('clears the product assignment when productId is null', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 10 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
productId: 'p1',
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, productId: null });
await service.update('b1', { productId: null }, 'user-1');
expect(prisma.product.findUnique).not.toHaveBeenCalled();
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
[{ where: { id: string }; data: { productId: string | null } }],
];
expect(updateArgs[0].where).toEqual({ id: 'b1' });
expect(updateArgs[0].data.productId).toBeNull();
});
it('releases the previous amount before re-reserving a corrected jar count', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 0 },
jarSize: 500,
jarCount: 20,
totalAmount: 10,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, jarCount: 10, totalAmount: 5 });
await service.update('b1', { jarCount: 10 }, 'user-1');
const [updateArgs] = tx.bottlingBatch.update.mock.calls as [
[{ data: { jarCount: number; totalAmount: number } }],
];
expect(updateArgs[0].data.jarCount).toBe(10);
expect(updateArgs[0].data.totalAmount).toBe(5);
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 5 },
});
});
it('rejects a corrected amount that exceeds the honey batch capacity', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 0 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
});
await expect(
service.update('b1', { jarCount: 40 }, 'user-1'),
).rejects.toBeInstanceOf(BadRequestException);
});
it('moves the amount between honey batches when the honey batch changes', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
jarSize: 500,
jarCount: 10,
totalAmount: 5,
};
prisma.bottlingBatch.findUnique
.mockResolvedValueOnce(before)
.mockResolvedValueOnce({ ...before, honeyBatchId: 'h2' });
prisma.honeyBatch.findUnique.mockResolvedValueOnce({ id: 'h2', amount: 8 });
await service.update('b1', { honeyBatchId: 'h2' }, 'user-1');
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 7 },
});
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h2' },
data: { amount: 3, status: 'BOTTLED' },
});
});
it('remove gives the amount back and records a DELETE audit entry', async () => {
const before = {
id: 'b1',
honeyBatchId: 'h1',
honeyBatch: { id: 'h1', amount: 2 },
totalAmount: 5,
};
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(before);
const result = await service.remove('b1', 'user-1');
expect(result).toEqual({ id: 'b1' });
expect(tx.bottlingBatch.delete).toHaveBeenCalledWith({
where: { id: 'b1' },
});
expect(tx.honeyBatch.update).toHaveBeenCalledWith({
where: { id: 'h1' },
data: { amount: 7 },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'BottlingBatch',
entityId: 'b1',
action: 'DELETE',
beforeData: before,
}),
);
expect(webhooks.dispatch).toHaveBeenCalledWith(
'batch.updated',
before.honeyBatch,
);
});
it('remove throws NotFoundException for an unknown bottling batch', async () => {
prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
await expect(service.remove('missing', 'user-1')).rejects.toBeInstanceOf(
NotFoundException,
);
});
});