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

@@ -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',
}),
);
});
});
});