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:
1 parent
961ca04a40
commit
2b15795a60
10 files changed
+632
-109
No files matched your search
@@ -191,6 +191,7 @@ export const PERMISSION_CATALOG = [
|
|||||||
['read', 'Ansehen'],
|
['read', 'Ansehen'],
|
||||||
['create', 'Anlegen'],
|
['create', 'Anlegen'],
|
||||||
['update', 'Bearbeiten'],
|
['update', 'Bearbeiten'],
|
||||||
|
['delete', 'Löschen'],
|
||||||
],
|
],
|
||||||
1600,
|
1600,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -51,4 +52,10 @@ export class BottlingController {
|
|||||||
) {
|
) {
|
||||||
return this.bottlingService.update(id, dto, actor.id);
|
return this.bottlingService.update(id, dto, actor.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@RequirePermission('bottling:delete')
|
||||||
|
@Delete(':id')
|
||||||
|
remove(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
|
||||||
|
return this.bottlingService.remove(id, actor.id);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -20,13 +20,18 @@ describe('BottlingService', () => {
|
|||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
let tx: {
|
let tx: {
|
||||||
bottlingBatch: { create: jest.Mock };
|
bottlingBatch: { create: jest.Mock; update: jest.Mock; delete: jest.Mock };
|
||||||
honeyBatch: { update: jest.Mock };
|
honeyBatch: { update: jest.Mock };
|
||||||
};
|
};
|
||||||
|
let webhooks: { dispatch: jest.Mock };
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
tx = {
|
tx = {
|
||||||
bottlingBatch: { create: jest.fn() },
|
bottlingBatch: {
|
||||||
|
create: jest.fn(),
|
||||||
|
update: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
honeyBatch: { update: jest.fn() },
|
honeyBatch: { update: jest.fn() },
|
||||||
};
|
};
|
||||||
prisma = {
|
prisma = {
|
||||||
@@ -43,7 +48,7 @@ describe('BottlingService', () => {
|
|||||||
),
|
),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||||
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
const automation = { generateNumber: jest.fn().mockResolvedValue(null) };
|
||||||
service = new BottlingService(
|
service = new BottlingService(
|
||||||
prisma as unknown as PrismaService,
|
prisma as unknown as PrismaService,
|
||||||
@@ -131,12 +136,19 @@ describe('BottlingService', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('update writes an audit log entry with before and after data', async () => {
|
it('update writes an audit log entry with before and after data', async () => {
|
||||||
const before = { id: 'b1', lotNumber: 'L-1' };
|
const before = {
|
||||||
const updated = { id: 'b1', lotNumber: 'L-2' };
|
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
|
prisma.bottlingBatch.findUnique
|
||||||
.mockResolvedValueOnce(before)
|
.mockResolvedValueOnce(before)
|
||||||
.mockResolvedValueOnce(updated);
|
.mockResolvedValueOnce(updated);
|
||||||
prisma.bottlingBatch.update.mockResolvedValue(updated);
|
|
||||||
|
|
||||||
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
const result = await service.update('b1', { lotNumber: 'L-2' }, 'user-1');
|
||||||
|
|
||||||
@@ -160,4 +172,136 @@ describe('BottlingService', () => {
|
|||||||
service.update('b1', { productId: 'missing' }, 'user-1'),
|
service.update('b1', { productId: 'missing' }, 'user-1'),
|
||||||
).rejects.toBeInstanceOf(NotFoundException);
|
).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,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
@@ -20,6 +20,15 @@ export interface BottlingBatchFilter {
|
|||||||
honeyBatchId?: string;
|
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()
|
@Injectable()
|
||||||
export class BottlingService {
|
export class BottlingService {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -122,8 +131,16 @@ export class BottlingService {
|
|||||||
return created;
|
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) {
|
async update(id: string, dto: UpdateBottlingBatchDto, actingUserId: string) {
|
||||||
const before = await this.findById(id);
|
const before = await this.findById(id);
|
||||||
|
|
||||||
if (dto.productId) {
|
if (dto.productId) {
|
||||||
const product = await this.prisma.product.findUnique({
|
const product = await this.prisma.product.findUnique({
|
||||||
where: { id: dto.productId },
|
where: { id: dto.productId },
|
||||||
@@ -132,16 +149,87 @@ export class BottlingService {
|
|||||||
throw new NotFoundException('Product not found');
|
throw new NotFoundException('Product not found');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.prisma.bottlingBatch.update({
|
|
||||||
where: { id },
|
const honeyBatchId = dto.honeyBatchId ?? before.honeyBatchId;
|
||||||
data: {
|
const movedToOtherHoneyBatch = honeyBatchId !== before.honeyBatchId;
|
||||||
...dto,
|
const jarSize = dto.jarSize ?? before.jarSize;
|
||||||
date: dto.date ? new Date(dto.date) : undefined,
|
const jarCount = dto.jarCount ?? before.jarCount;
|
||||||
bestBeforeDate: dto.bestBeforeDate
|
const totalAmount = roundKg((jarSize * jarCount) / 1000);
|
||||||
? new Date(dto.bestBeforeDate)
|
|
||||||
: undefined,
|
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);
|
const bottlingBatch = await this.findById(id);
|
||||||
await this.auditLog.record({
|
await this.auditLog.record({
|
||||||
entityType: 'BottlingBatch',
|
entityType: 'BottlingBatch',
|
||||||
@@ -151,6 +239,42 @@ export class BottlingService {
|
|||||||
beforeData: before,
|
beforeData: before,
|
||||||
afterData: bottlingBatch,
|
afterData: bottlingBatch,
|
||||||
});
|
});
|
||||||
|
await this.webhooks.dispatch('batch.updated', bottlingBatch.honeyBatch);
|
||||||
return bottlingBatch;
|
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 };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,63 @@
|
|||||||
import { PartialType, PickType } from '@nestjs/swagger';
|
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
import { CreateBottlingBatchDto } from './create-bottling-batch.dto';
|
import {
|
||||||
|
IsDateString,
|
||||||
|
IsInt,
|
||||||
|
IsNumber,
|
||||||
|
IsOptional,
|
||||||
|
IsPositive,
|
||||||
|
IsString,
|
||||||
|
ValidateIf,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UpdateBottlingBatchDto extends PartialType(
|
/**
|
||||||
PickType(CreateBottlingBatchDto, [
|
* Every field of a bottling batch stays correctable after the fact - a
|
||||||
'productId',
|
* mistyped jar count or a batch bottled against the wrong honey batch is a
|
||||||
'date',
|
* data-entry error, not a fact of production. The service rebalances the
|
||||||
'bestBeforeDate',
|
* affected honey batch amounts for any change to honeyBatchId/jarSize/
|
||||||
'lotNumber',
|
* jarCount, so the traceability chain stays consistent.
|
||||||
] as const),
|
*
|
||||||
) {}
|
* `productId: null` explicitly clears the product assignment; omitting the
|
||||||
|
* field leaves it untouched.
|
||||||
|
*/
|
||||||
|
export class UpdateBottlingBatchDto {
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
honeyBatchId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
nullable: true,
|
||||||
|
description: 'null entfernt die Produktzuordnung',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@ValidateIf((_object, value) => value !== null)
|
||||||
|
@IsString()
|
||||||
|
productId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
date?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Glasgröße in Gramm' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsNumber()
|
||||||
|
@IsPositive()
|
||||||
|
jarSize?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@IsPositive()
|
||||||
|
jarCount?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
bestBeforeDate?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
lotNumber?: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { deleteBottlingBatch } from "@/lib/bottling/api";
|
||||||
|
import { BottlingBatch } from "@/lib/bottling/types";
|
||||||
|
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deleting a bottling batch is the correction path for a mis-entered record:
|
||||||
|
* the bottled amount goes back to the honey batch and the full row is kept in
|
||||||
|
* the audit log. It also retires the lot number, so the public origin page
|
||||||
|
* /herkunft/<lotNumber> stops resolving - hence the explicit confirmation.
|
||||||
|
*/
|
||||||
|
export function BottlingDeleteButton({
|
||||||
|
bottlingBatch,
|
||||||
|
}: {
|
||||||
|
bottlingBatch: BottlingBatch;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () => deleteBottlingBatch(bottlingBatch.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||||
|
toast.success("Abfüllung gelöscht");
|
||||||
|
router.push("/bottling");
|
||||||
|
},
|
||||||
|
onError: (error: Error) =>
|
||||||
|
toast.error(error.message || "Abfüllung konnte nicht gelöscht werden"),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfirmDialog
|
||||||
|
triggerLabel="Löschen"
|
||||||
|
triggerVariant="outline"
|
||||||
|
triggerSize="sm"
|
||||||
|
title="Abfüllung löschen"
|
||||||
|
description={`Abfüllung ${bottlingBatch.lotNumber} wird entfernt und ${bottlingBatch.totalAmount} kg werden der Honigcharge ${bottlingBatch.honeyBatch.batchNumber} wieder gutgeschrieben. Die öffentliche Herkunftsseite zu dieser Los-/Chargennummer ist danach nicht mehr erreichbar.`}
|
||||||
|
confirmLabel="Endgültig löschen"
|
||||||
|
onConfirm={() => mutation.mutate()}
|
||||||
|
isPending={mutation.isPending}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { updateBottlingBatch } from "@/lib/bottling/api";
|
import { updateBottlingBatch } from "@/lib/bottling/api";
|
||||||
import { BottlingBatch } from "@/lib/bottling/types";
|
import { BottlingBatch } from "@/lib/bottling/types";
|
||||||
|
import { fetchHoneyBatches } from "@/lib/honey-batches/api";
|
||||||
import { fetchProducts } from "@/lib/products/api";
|
import { fetchProducts } from "@/lib/products/api";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
@@ -25,42 +26,19 @@ import {
|
|||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
|
|
||||||
export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingBatch }) {
|
/**
|
||||||
const queryClient = useQueryClient();
|
* Sentinel for "no product": the select needs a selectable item to clear the
|
||||||
|
* assignment again, and an empty string would be read as "nothing chosen yet"
|
||||||
|
* and render the placeholder instead.
|
||||||
|
*/
|
||||||
|
const NO_PRODUCT = "__none__";
|
||||||
|
|
||||||
|
export function BottlingEditDialog({
|
||||||
|
bottlingBatch,
|
||||||
|
}: {
|
||||||
|
bottlingBatch: BottlingBatch;
|
||||||
|
}) {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
|
|
||||||
const [bestBeforeDate, setBestBeforeDate] = useState(
|
|
||||||
bottlingBatch.bestBeforeDate.slice(0, 10),
|
|
||||||
);
|
|
||||||
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
|
|
||||||
const [productId, setProductId] = useState(bottlingBatch.productId ?? "");
|
|
||||||
|
|
||||||
const { data: products } = useQuery({
|
|
||||||
queryKey: ["products", { status: "ACTIVE" }],
|
|
||||||
queryFn: () => fetchProducts({ status: "ACTIVE" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const mutation = useMutation({
|
|
||||||
mutationFn: () =>
|
|
||||||
updateBottlingBatch(bottlingBatch.id, {
|
|
||||||
date: new Date(date).toISOString(),
|
|
||||||
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
|
|
||||||
lotNumber,
|
|
||||||
productId: productId || undefined,
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: ["bottling-batches", bottlingBatch.id],
|
|
||||||
});
|
|
||||||
setOpen(false);
|
|
||||||
},
|
|
||||||
onError: () => toast.error("Abfüllung konnte nicht gespeichert werden"),
|
|
||||||
});
|
|
||||||
|
|
||||||
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
mutation.mutate();
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
@@ -76,54 +54,205 @@ export function BottlingEditDialog({ bottlingBatch }: { bottlingBatch: BottlingB
|
|||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>Abfüllung bearbeiten</DialogTitle>
|
<DialogTitle>Abfüllung bearbeiten</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
{/* Mounted only while the dialog is open, so every reopen starts from
|
||||||
<div className="flex flex-col gap-2">
|
the saved values instead of what a cancelled edit left behind. */}
|
||||||
<Label htmlFor="bottling-edit-date">Datum</Label>
|
{open ? (
|
||||||
<Input
|
<BottlingEditForm
|
||||||
id="bottling-edit-date"
|
bottlingBatch={bottlingBatch}
|
||||||
type="date"
|
onSaved={() => setOpen(false)}
|
||||||
value={date}
|
/>
|
||||||
onChange={(event) => setDate(event.target.value)}
|
) : null}
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="bottling-edit-best-before">Mindesthaltbarkeitsdatum</Label>
|
|
||||||
<Input
|
|
||||||
id="bottling-edit-best-before"
|
|
||||||
type="date"
|
|
||||||
value={bestBeforeDate}
|
|
||||||
onChange={(event) => setBestBeforeDate(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="bottling-edit-product">Produkt</Label>
|
|
||||||
<Select value={productId} onValueChange={(value) => setProductId(value ?? "")}>
|
|
||||||
<SelectTrigger id="bottling-edit-product">
|
|
||||||
<SelectValue placeholder="Produkt wählen" />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{products?.map((product) => (
|
|
||||||
<SelectItem key={product.id} value={product.id}>
|
|
||||||
{product.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
|
|
||||||
<Input
|
|
||||||
id="bottling-edit-lot"
|
|
||||||
required
|
|
||||||
value={lotNumber}
|
|
||||||
onChange={(event) => setLotNumber(event.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
|
||||||
{mutation.isPending ? "Speichert…" : "Speichern"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function BottlingEditForm({
|
||||||
|
bottlingBatch,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
bottlingBatch: BottlingBatch;
|
||||||
|
onSaved: () => void;
|
||||||
|
}) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [honeyBatchId, setHoneyBatchId] = useState(bottlingBatch.honeyBatchId);
|
||||||
|
const [date, setDate] = useState(bottlingBatch.date.slice(0, 10));
|
||||||
|
const [bestBeforeDate, setBestBeforeDate] = useState(
|
||||||
|
bottlingBatch.bestBeforeDate.slice(0, 10),
|
||||||
|
);
|
||||||
|
const [jarSize, setJarSize] = useState(String(bottlingBatch.jarSize));
|
||||||
|
const [jarCount, setJarCount] = useState(String(bottlingBatch.jarCount));
|
||||||
|
const [lotNumber, setLotNumber] = useState(bottlingBatch.lotNumber);
|
||||||
|
const [productId, setProductId] = useState(
|
||||||
|
bottlingBatch.productId ?? NO_PRODUCT,
|
||||||
|
);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: honeyBatches } = useQuery({
|
||||||
|
queryKey: ["honey-batches"],
|
||||||
|
queryFn: () => fetchHoneyBatches(),
|
||||||
|
});
|
||||||
|
const { data: products } = useQuery({
|
||||||
|
queryKey: ["products", { status: "ACTIVE" }],
|
||||||
|
queryFn: () => fetchProducts({ status: "ACTIVE" }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// The assigned product may have been discontinued since bottling - keep it
|
||||||
|
// selectable so opening the dialog does not silently drop the assignment.
|
||||||
|
const productOptions = [
|
||||||
|
...(products ?? []).map((product) => ({
|
||||||
|
id: product.id,
|
||||||
|
name: product.name,
|
||||||
|
})),
|
||||||
|
...(bottlingBatch.product &&
|
||||||
|
!(products ?? []).some(
|
||||||
|
(product) => product.id === bottlingBatch.product?.id,
|
||||||
|
)
|
||||||
|
? [bottlingBatch.product]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
const honeyBatchOptions = [
|
||||||
|
...(honeyBatches ?? []).map((honeyBatch) => ({
|
||||||
|
id: honeyBatch.id,
|
||||||
|
batchNumber: honeyBatch.batchNumber,
|
||||||
|
})),
|
||||||
|
...((honeyBatches ?? []).some(
|
||||||
|
(honeyBatch) => honeyBatch.id === bottlingBatch.honeyBatchId,
|
||||||
|
)
|
||||||
|
? []
|
||||||
|
: [bottlingBatch.honeyBatch]),
|
||||||
|
];
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
updateBottlingBatch(bottlingBatch.id, {
|
||||||
|
honeyBatchId,
|
||||||
|
date: new Date(date).toISOString(),
|
||||||
|
bestBeforeDate: new Date(bestBeforeDate).toISOString(),
|
||||||
|
jarSize: Number(jarSize),
|
||||||
|
jarCount: Number(jarCount),
|
||||||
|
lotNumber,
|
||||||
|
productId: productId === NO_PRODUCT ? null : productId,
|
||||||
|
}),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["bottling-batches"] });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["honey-batches"] });
|
||||||
|
onSaved();
|
||||||
|
},
|
||||||
|
onError: (mutationError: Error) => {
|
||||||
|
const message =
|
||||||
|
mutationError.message || "Abfüllung konnte nicht gespeichert werden";
|
||||||
|
setError(message);
|
||||||
|
toast.error(message);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
mutation.mutate();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form className="flex flex-col gap-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bottling-edit-honey-batch">Honigcharge</Label>
|
||||||
|
<Select
|
||||||
|
value={honeyBatchId}
|
||||||
|
onValueChange={(value) => setHoneyBatchId(value ?? honeyBatchId)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="bottling-edit-honey-batch">
|
||||||
|
<SelectValue placeholder="Charge wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{honeyBatchOptions.map((honeyBatch) => (
|
||||||
|
<SelectItem key={honeyBatch.id} value={honeyBatch.id}>
|
||||||
|
{honeyBatch.batchNumber}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bottling-edit-date">Datum</Label>
|
||||||
|
<Input
|
||||||
|
id="bottling-edit-date"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={date}
|
||||||
|
onChange={(event) => setDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bottling-edit-best-before">
|
||||||
|
Mindesthaltbarkeitsdatum
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="bottling-edit-best-before"
|
||||||
|
type="date"
|
||||||
|
required
|
||||||
|
value={bestBeforeDate}
|
||||||
|
onChange={(event) => setBestBeforeDate(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bottling-edit-jar-size">Glasgröße (g)</Label>
|
||||||
|
<Input
|
||||||
|
id="bottling-edit-jar-size"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
value={jarSize}
|
||||||
|
onChange={(event) => setJarSize(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bottling-edit-jar-count">Anzahl Gläser</Label>
|
||||||
|
<Input
|
||||||
|
id="bottling-edit-jar-count"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
required
|
||||||
|
value={jarCount}
|
||||||
|
onChange={(event) => setJarCount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bottling-edit-product">Produkt</Label>
|
||||||
|
<Select
|
||||||
|
value={productId}
|
||||||
|
onValueChange={(value) => setProductId(value ?? NO_PRODUCT)}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="bottling-edit-product">
|
||||||
|
<SelectValue placeholder="Produkt wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value={NO_PRODUCT}>Kein Produkt</SelectItem>
|
||||||
|
{productOptions.map((product) => (
|
||||||
|
<SelectItem key={product.id} value={product.id}>
|
||||||
|
{product.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="bottling-edit-lot">Los-/Chargennummer</Label>
|
||||||
|
<Input
|
||||||
|
id="bottling-edit-lot"
|
||||||
|
required
|
||||||
|
value={lotNumber}
|
||||||
|
onChange={(event) => setLotNumber(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? "Speichert…" : "Speichern"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -7,11 +7,14 @@ import { useQuery } from "@tanstack/react-query";
|
|||||||
import { fetchBottlingBatch } from "@/lib/bottling/api";
|
import { fetchBottlingBatch } from "@/lib/bottling/api";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
import { QrCodeDialog } from "@/components/qr-code-dialog";
|
||||||
|
import { useAuth } from "@/lib/auth/auth-context";
|
||||||
import { BottlingEditDialog } from "./bottling-edit-dialog";
|
import { BottlingEditDialog } from "./bottling-edit-dialog";
|
||||||
|
import { BottlingDeleteButton } from "./bottling-delete-button";
|
||||||
import { PrintLabelsCard } from "./print-labels-card";
|
import { PrintLabelsCard } from "./print-labels-card";
|
||||||
|
|
||||||
export default function BottlingBatchDetailPage() {
|
export default function BottlingBatchDetailPage() {
|
||||||
const params = useParams<{ id: string }>();
|
const params = useParams<{ id: string }>();
|
||||||
|
const { hasPermission } = useAuth();
|
||||||
const { data: bottlingBatch, isLoading, isError } = useQuery({
|
const { data: bottlingBatch, isLoading, isError } = useQuery({
|
||||||
queryKey: ["bottling-batches", params.id],
|
queryKey: ["bottling-batches", params.id],
|
||||||
queryFn: () => fetchBottlingBatch(params.id),
|
queryFn: () => fetchBottlingBatch(params.id),
|
||||||
@@ -43,7 +46,12 @@ export default function BottlingBatchDetailPage() {
|
|||||||
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
|
<CardTitle className="font-mono">Abfüllung {bottlingBatch.lotNumber}</CardTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<BottlingEditDialog bottlingBatch={bottlingBatch} />
|
{hasPermission("bottling:update") ? (
|
||||||
|
<BottlingEditDialog bottlingBatch={bottlingBatch} />
|
||||||
|
) : null}
|
||||||
|
{hasPermission("bottling:delete") ? (
|
||||||
|
<BottlingDeleteButton bottlingBatch={bottlingBatch} />
|
||||||
|
) : null}
|
||||||
<QrCodeDialog
|
<QrCodeDialog
|
||||||
entityType="bottling-batches"
|
entityType="bottling-batches"
|
||||||
id={bottlingBatch.id}
|
id={bottlingBatch.id}
|
||||||
|
|||||||
@@ -37,3 +37,9 @@ export function updateBottlingBatch(
|
|||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function deleteBottlingBatch(id: string): Promise<{ id: string }> {
|
||||||
|
return apiFetch<{ id: string }>(`/bottling-batches/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -24,8 +24,12 @@ export interface CreateBottlingBatchInput {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateBottlingBatchInput {
|
export interface UpdateBottlingBatchInput {
|
||||||
productId?: string;
|
honeyBatchId?: string;
|
||||||
|
/** `null` removes the product assignment, `undefined` leaves it untouched. */
|
||||||
|
productId?: string | null;
|
||||||
date?: string;
|
date?: string;
|
||||||
|
jarSize?: number;
|
||||||
|
jarCount?: number;
|
||||||
bestBeforeDate?: string;
|
bestBeforeDate?: string;
|
||||||
lotNumber?: string;
|
lotNumber?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user