feat: allow a honey batch to be split across multiple storage containers
Backend Image / build-and-push (push) Successful in 3m9s
Frontend Image / build-and-push (push) Successful in 2m45s

A batch that doesn't fit in one container (e.g. 125 kg across 5 x 25 kg
buckets) had no way to be represented - HoneyBatch.storageContainerId
is a single nullable FK. Adds HoneyBatchContainerAllocation as an
additive join table (container + amount per batch), independent of the
existing single-container/relocate() flow which still covers the
simple one-container case unchanged.

- GET/PUT/DELETE /honey-batches/:id/containers[/:containerId], reusing
  the existing honey-batches:relocate permission.
- Assigning a container is an upsert (@@unique on batch+container), and
  the total assigned is validated against the batch's own amount when
  known.
- New "Aufteilung auf mehrere Behälter" card on the honey batch detail
  page, alongside (not replacing) the existing storage card.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
This commit is contained in:
Nick SzaunigandClaude Sonnet 5 committed 2026-09-04 19:44:33 +02:00
1 parent c91dc9ebf1
commit 1054a845ef
10 files changed
+579 -7

No files matched your search

@@ -0,0 +1,9 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsNumber, IsPositive } from 'class-validator';
export class SetHoneyBatchContainerAllocationDto {
@ApiProperty({ description: 'Menge in kg in diesem Behälter' })
@IsNumber()
@IsPositive()
amount!: number;
}
@@ -1,10 +1,12 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Put,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
@@ -16,6 +18,7 @@ import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
import { HoneyBatchesService } from './honey-batches.service';
@ApiTags('honey-batches')
@@ -48,6 +51,42 @@ export class HoneyBatchesController {
return this.honeyBatchesService.findRelocations(id);
}
@RequirePermission('honey-batches:read')
@Get(':id/containers')
listContainerAllocations(@Param('id') id: string) {
return this.honeyBatchesService.listContainerAllocations(id);
}
@RequirePermission('honey-batches:relocate')
@Put(':id/containers/:containerId')
setContainerAllocation(
@Param('id') id: string,
@Param('containerId') containerId: string,
@Body() dto: SetHoneyBatchContainerAllocationDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.honeyBatchesService.setContainerAllocation(
id,
containerId,
dto,
actor.id,
);
}
@RequirePermission('honey-batches:relocate')
@Delete(':id/containers/:containerId')
removeContainerAllocation(
@Param('id') id: string,
@Param('containerId') containerId: string,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.honeyBatchesService.removeContainerAllocation(
id,
containerId,
actor.id,
);
}
@RequirePermission('honey-batches:relocate')
@Post(':id/relocate')
relocate(
@@ -1,4 +1,4 @@
import { NotFoundException } from '@nestjs/common';
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { HoneyBatchesService } from './honey-batches.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
@@ -17,6 +17,12 @@ describe('HoneyBatchesService', () => {
honeyMeasurement: { create: jest.Mock };
storageContainer: { findUnique: jest.Mock };
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
honeyBatchContainerAllocation: {
findMany: jest.Mock;
findUnique: jest.Mock;
upsert: jest.Mock;
delete: jest.Mock;
};
$transaction: jest.Mock;
};
let auditLog: { record: jest.Mock };
@@ -57,6 +63,12 @@ describe('HoneyBatchesService', () => {
honeyMeasurement: { create: jest.fn() },
storageContainer: { findUnique: jest.fn() },
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
honeyBatchContainerAllocation: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn(),
upsert: jest.fn(),
delete: jest.fn(),
},
$transaction: jest.fn(),
};
auditLog = { record: jest.fn() };
@@ -199,4 +211,147 @@ describe('HoneyBatchesService', () => {
expect(findArg.where.honeyBatchId).toBe('batch-1');
});
});
describe('setContainerAllocation', () => {
it('throws NotFoundException when the container does not exist', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
prisma.storageContainer.findUnique.mockResolvedValue(null);
await expect(
service.setContainerAllocation(
'batch-1',
'missing',
{ amount: 25 },
'user-1',
),
).rejects.toBeInstanceOf(NotFoundException);
});
it('allows splitting the batch amount across multiple containers (125 kg / 5 x 25 kg)', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
amount: 125,
});
prisma.storageContainer.findUnique.mockResolvedValue({
id: 'bucket-5',
});
prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([
{ storageContainerId: 'bucket-1', amount: 25 },
{ storageContainerId: 'bucket-2', amount: 25 },
{ storageContainerId: 'bucket-3', amount: 25 },
{ storageContainerId: 'bucket-4', amount: 25 },
]);
prisma.honeyBatchContainerAllocation.upsert.mockResolvedValue({
id: 'allocation-5',
honeyBatchId: 'batch-1',
storageContainerId: 'bucket-5',
amount: 25,
});
await service.setContainerAllocation(
'batch-1',
'bucket-5',
{ amount: 25 },
'user-1',
);
expect(prisma.honeyBatchContainerAllocation.upsert).toHaveBeenCalledWith({
where: {
honeyBatchId_storageContainerId: {
honeyBatchId: 'batch-1',
storageContainerId: 'bucket-5',
},
},
create: {
honeyBatchId: 'batch-1',
storageContainerId: 'bucket-5',
amount: 25,
},
update: { amount: 25 },
include: { storageContainer: true },
});
});
it('rejects an allocation that would exceed the batch amount', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
amount: 100,
});
prisma.storageContainer.findUnique.mockResolvedValue({ id: 'bucket-1' });
prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([
{ storageContainerId: 'bucket-2', amount: 80 },
]);
await expect(
service.setContainerAllocation(
'batch-1',
'bucket-1',
{ amount: 30 },
'user-1',
),
).rejects.toBeInstanceOf(BadRequestException);
expect(
prisma.honeyBatchContainerAllocation.upsert,
).not.toHaveBeenCalled();
});
it('re-assigning the same container overwrites its own amount rather than double-counting it', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue({
...honeyBatch,
amount: 100,
});
prisma.storageContainer.findUnique.mockResolvedValue({ id: 'bucket-1' });
// findMany excludes the container being set (storageContainerId: { not: ... }),
// so its own prior amount never counts against itself here.
prisma.honeyBatchContainerAllocation.findMany.mockResolvedValue([]);
prisma.honeyBatchContainerAllocation.upsert.mockResolvedValue({
id: 'allocation-1',
honeyBatchId: 'batch-1',
storageContainerId: 'bucket-1',
amount: 90,
});
await expect(
service.setContainerAllocation(
'batch-1',
'bucket-1',
{ amount: 90 },
'user-1',
),
).resolves.toBeDefined();
});
});
describe('removeContainerAllocation', () => {
it('throws NotFoundException when no allocation exists for that container', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
prisma.honeyBatchContainerAllocation.findUnique.mockResolvedValue(null);
await expect(
service.removeContainerAllocation('batch-1', 'bucket-1', 'user-1'),
).rejects.toBeInstanceOf(NotFoundException);
});
it('deletes the allocation and records an audit entry', async () => {
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
prisma.honeyBatchContainerAllocation.findUnique.mockResolvedValue({
id: 'allocation-1',
honeyBatchId: 'batch-1',
storageContainerId: 'bucket-1',
amount: 25,
});
await service.removeContainerAllocation('batch-1', 'bucket-1', 'user-1');
expect(prisma.honeyBatchContainerAllocation.delete).toHaveBeenCalledWith({
where: { id: 'allocation-1' },
});
expect(auditLog.record).toHaveBeenCalledWith(
expect.objectContaining({
entityType: 'HoneyBatchContainerAllocation',
action: 'DELETE',
}),
);
});
});
});
@@ -12,6 +12,7 @@ import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
import { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
const honeyBatchInclude = {
measurements: true,
@@ -160,6 +161,101 @@ export class HoneyBatchesService {
return this.findById(id);
}
// Additive to the single storageContainerId/relocate() flow above: covers
// a batch too large for one container (e.g. 125 kg split across 5 x 25 kg
// buckets). @@unique on [honeyBatchId, storageContainerId] makes this an
// upsert - re-assigning the same container just overwrites its amount.
async listContainerAllocations(id: string) {
await this.findById(id);
return this.prisma.honeyBatchContainerAllocation.findMany({
where: { honeyBatchId: id },
include: { storageContainer: true },
orderBy: { createdAt: 'asc' },
});
}
async setContainerAllocation(
id: string,
storageContainerId: string,
dto: SetHoneyBatchContainerAllocationDto,
actingUserId: string,
) {
const honeyBatch = await this.findById(id);
const container = await this.prisma.storageContainer.findUnique({
where: { id: storageContainerId },
});
if (!container) {
throw new NotFoundException('Storage container not found');
}
const otherAllocations =
await this.prisma.honeyBatchContainerAllocation.findMany({
where: {
honeyBatchId: id,
storageContainerId: { not: storageContainerId },
},
});
const otherTotal = otherAllocations.reduce((sum, a) => sum + a.amount, 0);
if (
honeyBatch.amount !== null &&
otherTotal + dto.amount > honeyBatch.amount
) {
throw new BadRequestException(
`Zugewiesene Menge (${otherTotal + dto.amount} kg) übersteigt die Chargenmenge (${honeyBatch.amount} kg)`,
);
}
const allocation = await this.prisma.honeyBatchContainerAllocation.upsert({
where: {
honeyBatchId_storageContainerId: {
honeyBatchId: id,
storageContainerId,
},
},
create: { honeyBatchId: id, storageContainerId, amount: dto.amount },
update: { amount: dto.amount },
include: { storageContainer: true },
});
await this.auditLog.record({
entityType: 'HoneyBatchContainerAllocation',
entityId: allocation.id,
action: 'UPDATE',
userId: actingUserId,
afterData: allocation,
});
return allocation;
}
async removeContainerAllocation(
id: string,
storageContainerId: string,
actingUserId: string,
): Promise<void> {
await this.findById(id);
const allocation =
await this.prisma.honeyBatchContainerAllocation.findUnique({
where: {
honeyBatchId_storageContainerId: {
honeyBatchId: id,
storageContainerId,
},
},
});
if (!allocation) {
throw new NotFoundException('Zuweisung nicht gefunden');
}
await this.prisma.honeyBatchContainerAllocation.delete({
where: { id: allocation.id },
});
await this.auditLog.record({
entityType: 'HoneyBatchContainerAllocation',
entityId: allocation.id,
action: 'DELETE',
userId: actingUserId,
beforeData: allocation,
});
}
async findRelocations(id: string) {
await this.findById(id);
return this.prisma.honeyBatchRelocation.findMany({