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
@@ -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({
|
||||
|
||||
Reference in new issue
Block a user