feat: allow a honey batch to be split across multiple storage containers
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:
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',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user