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
+573
-1
No files matched your search
+23
@@ -0,0 +1,23 @@
|
|||||||
|
-- CreateTable
|
||||||
|
CREATE TABLE "HoneyBatchContainerAllocation" (
|
||||||
|
"id" TEXT NOT NULL,
|
||||||
|
"honeyBatchId" TEXT NOT NULL,
|
||||||
|
"storageContainerId" TEXT NOT NULL,
|
||||||
|
"amount" DOUBLE PRECISION NOT NULL,
|
||||||
|
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||||
|
|
||||||
|
CONSTRAINT "HoneyBatchContainerAllocation_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "HoneyBatchContainerAllocation_storageContainerId_idx" ON "HoneyBatchContainerAllocation"("storageContainerId");
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE UNIQUE INDEX "HoneyBatchContainerAllocation_honeyBatchId_storageContaine_key" ON "HoneyBatchContainerAllocation"("honeyBatchId", "storageContainerId");
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
-- AddForeignKey
|
||||||
|
ALTER TABLE "HoneyBatchContainerAllocation" ADD CONSTRAINT "HoneyBatchContainerAllocation_storageContainerId_fkey" FOREIGN KEY ("storageContainerId") REFERENCES "StorageContainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||||
@@ -711,6 +711,7 @@ model HoneyBatch {
|
|||||||
measurements HoneyMeasurement[]
|
measurements HoneyMeasurement[]
|
||||||
bottlingBatches BottlingBatch[]
|
bottlingBatches BottlingBatch[]
|
||||||
relocations HoneyBatchRelocation[]
|
relocations HoneyBatchRelocation[]
|
||||||
|
containerAllocations HoneyBatchContainerAllocation[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@index([extractionId])
|
@@index([extractionId])
|
||||||
@@ -759,6 +760,7 @@ model StorageContainer {
|
|||||||
honeyBatches HoneyBatch[]
|
honeyBatches HoneyBatch[]
|
||||||
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
|
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
|
||||||
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
|
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
|
||||||
|
batchAllocations HoneyBatchContainerAllocation[]
|
||||||
|
|
||||||
@@index([status])
|
@@index([status])
|
||||||
}
|
}
|
||||||
@@ -781,6 +783,27 @@ model HoneyBatchRelocation {
|
|||||||
@@index([honeyBatchId, relocatedAt])
|
@@index([honeyBatchId, relocatedAt])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Aufteilung einer Honigcharge auf mehrere Lagerbehälter (z.B. 125 kg auf 5
|
||||||
|
// Eimer à 25 kg) - additiv zum obigen "primären" storageContainerId/
|
||||||
|
// Umlagern-Fluss auf HoneyBatch, der weiterhin den einfachen Ein-Behälter-
|
||||||
|
// Fall abdeckt; diese Tabelle bildet zusätzlich den Fall ab, dass eine
|
||||||
|
// Charge zu groß für einen einzelnen Behälter ist. @@unique macht das
|
||||||
|
// Zuweisen einer Menge zu einem Behälter ein Upsert (erneutes Zuweisen
|
||||||
|
// überschreibt die Menge, statt einen Duplikat-Eintrag anzulegen).
|
||||||
|
model HoneyBatchContainerAllocation {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
honeyBatchId String
|
||||||
|
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
|
||||||
|
storageContainerId String
|
||||||
|
storageContainer StorageContainer @relation(fields: [storageContainerId], references: [id])
|
||||||
|
amount Float
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@unique([honeyBatchId, storageContainerId])
|
||||||
|
@@index([storageContainerId])
|
||||||
|
}
|
||||||
|
|
||||||
enum StockCategory {
|
enum StockCategory {
|
||||||
HONEY
|
HONEY
|
||||||
CONSUMABLE
|
CONSUMABLE
|
||||||
|
|||||||
@@ -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 {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
Query,
|
Query,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
|
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 { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.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';
|
import { HoneyBatchesService } from './honey-batches.service';
|
||||||
|
|
||||||
@ApiTags('honey-batches')
|
@ApiTags('honey-batches')
|
||||||
@@ -48,6 +51,42 @@ export class HoneyBatchesController {
|
|||||||
return this.honeyBatchesService.findRelocations(id);
|
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')
|
@RequirePermission('honey-batches:relocate')
|
||||||
@Post(':id/relocate')
|
@Post(':id/relocate')
|
||||||
relocate(
|
relocate(
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NotFoundException } from '@nestjs/common';
|
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { HoneyBatchesService } from './honey-batches.service';
|
import { HoneyBatchesService } from './honey-batches.service';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditLogService } from '../audit/audit-log.service';
|
import { AuditLogService } from '../audit/audit-log.service';
|
||||||
@@ -17,6 +17,12 @@ describe('HoneyBatchesService', () => {
|
|||||||
honeyMeasurement: { create: jest.Mock };
|
honeyMeasurement: { create: jest.Mock };
|
||||||
storageContainer: { findUnique: jest.Mock };
|
storageContainer: { findUnique: jest.Mock };
|
||||||
honeyBatchRelocation: { create: jest.Mock; findMany: 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;
|
$transaction: jest.Mock;
|
||||||
};
|
};
|
||||||
let auditLog: { record: jest.Mock };
|
let auditLog: { record: jest.Mock };
|
||||||
@@ -57,6 +63,12 @@ describe('HoneyBatchesService', () => {
|
|||||||
honeyMeasurement: { create: jest.fn() },
|
honeyMeasurement: { create: jest.fn() },
|
||||||
storageContainer: { findUnique: jest.fn() },
|
storageContainer: { findUnique: jest.fn() },
|
||||||
honeyBatchRelocation: { create: jest.fn(), findMany: 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(),
|
$transaction: jest.fn(),
|
||||||
};
|
};
|
||||||
auditLog = { record: jest.fn() };
|
auditLog = { record: jest.fn() };
|
||||||
@@ -199,4 +211,147 @@ describe('HoneyBatchesService', () => {
|
|||||||
expect(findArg.where.honeyBatchId).toBe('batch-1');
|
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 { UpdateHoneyBatchDto } from './dto/update-honey-batch.dto';
|
||||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||||
|
import { SetHoneyBatchContainerAllocationDto } from './dto/set-honey-batch-container-allocation.dto';
|
||||||
|
|
||||||
const honeyBatchInclude = {
|
const honeyBatchInclude = {
|
||||||
measurements: true,
|
measurements: true,
|
||||||
@@ -160,6 +161,101 @@ export class HoneyBatchesService {
|
|||||||
return this.findById(id);
|
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) {
|
async findRelocations(id: string) {
|
||||||
await this.findById(id);
|
await this.findById(id);
|
||||||
return this.prisma.honeyBatchRelocation.findMany({
|
return this.prisma.honeyBatchRelocation.findMany({
|
||||||
|
|||||||
+190
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
import { Trash2 } from "lucide-react";
|
||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
fetchHoneyBatchContainerAllocations,
|
||||||
|
removeHoneyBatchContainerAllocation,
|
||||||
|
setHoneyBatchContainerAllocation,
|
||||||
|
} from "@/lib/honey-batches/api";
|
||||||
|
import { HoneyBatch } from "@/lib/honey-batches/types";
|
||||||
|
import { fetchStorageContainers } from "@/lib/storage/api";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
|
import { Label } from "@/components/ui/label";
|
||||||
|
import { EmptyState } from "@/components/empty-state";
|
||||||
|
import { Boxes } from "lucide-react";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@/components/ui/select";
|
||||||
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
|
|
||||||
|
// Additive to HoneyBatchStorageCard's single-container "Umlagern" flow above
|
||||||
|
// it on the page - covers a batch too large for one container (e.g. 125 kg
|
||||||
|
// split across 5 x 25 kg buckets), which a single storageContainerId can't
|
||||||
|
// represent.
|
||||||
|
export function HoneyBatchContainerAllocationsCard({ honeyBatch }: { honeyBatch: HoneyBatch }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [storageContainerId, setStorageContainerId] = useState("");
|
||||||
|
const [amount, setAmount] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const { data: containers } = useQuery({
|
||||||
|
queryKey: ["storage-containers"],
|
||||||
|
queryFn: fetchStorageContainers,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: allocations,
|
||||||
|
isLoading,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ["honey-batches", honeyBatch.id, "containers"],
|
||||||
|
queryFn: () => fetchHoneyBatchContainerAllocations(honeyBatch.id),
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignMutation = useMutation({
|
||||||
|
mutationFn: (input: { storageContainerId: string; amount: number }) =>
|
||||||
|
setHoneyBatchContainerAllocation(honeyBatch.id, input.storageContainerId, input.amount),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["honey-batches", honeyBatch.id, "containers"],
|
||||||
|
});
|
||||||
|
setStorageContainerId("");
|
||||||
|
setAmount("");
|
||||||
|
setError(null);
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
setError("Zuweisung konnte nicht gespeichert werden. Übersteigt die Menge die Charge?");
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeMutation = useMutation({
|
||||||
|
mutationFn: (containerId: string) =>
|
||||||
|
removeHoneyBatchContainerAllocation(honeyBatch.id, containerId),
|
||||||
|
onSuccess: () => {
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["honey-batches", honeyBatch.id, "containers"],
|
||||||
|
});
|
||||||
|
toast.success("Zuweisung entfernt");
|
||||||
|
},
|
||||||
|
onError: () => toast.error("Zuweisung konnte nicht entfernt werden"),
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
||||||
|
event.preventDefault();
|
||||||
|
setError(null);
|
||||||
|
if (!storageContainerId) {
|
||||||
|
setError("Bitte einen Lagerbehälter auswählen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const parsedAmount = Number(amount);
|
||||||
|
if (!amount || parsedAmount <= 0) {
|
||||||
|
setError("Bitte eine Menge größer als 0 angeben.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
assignMutation.mutate({ storageContainerId, amount: parsedAmount });
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalAllocated = (allocations ?? []).reduce((sum, a) => sum + a.amount, 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>Aufteilung auf mehrere Behälter</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="flex flex-col gap-4 text-sm">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Für Chargen, die nicht in einen einzigen Behälter passen (z. B. 125 kg auf 5 Eimer à 25
|
||||||
|
kg). Unabhängig vom Lagerbehälter oben.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{honeyBatch.amount !== null ? (
|
||||||
|
<p className="text-muted-foreground">
|
||||||
|
Zugewiesen: {totalAllocated} kg von {honeyBatch.amount} kg
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-muted-foreground">Lädt…</p> : null}
|
||||||
|
{!isLoading && (!allocations || allocations.length === 0) ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={Boxes}
|
||||||
|
title="Noch keine Aufteilung"
|
||||||
|
description="Weise dieser Charge unten mehrere Behälter mit jeweiliger Menge zu."
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{allocations?.map((allocation) => (
|
||||||
|
<div
|
||||||
|
key={allocation.id}
|
||||||
|
className="flex items-center justify-between rounded-md border p-3"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium">
|
||||||
|
{allocation.storageContainer.code}
|
||||||
|
{allocation.storageContainer.location
|
||||||
|
? ` · ${allocation.storageContainer.location}`
|
||||||
|
: ""}
|
||||||
|
</p>
|
||||||
|
<p className="text-muted-foreground">{allocation.amount} kg</p>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
disabled={removeMutation.isPending}
|
||||||
|
onClick={() => removeMutation.mutate(allocation.storageContainerId)}
|
||||||
|
aria-label="Zuweisung entfernen"
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<form className="flex flex-col gap-3 border-t border-border/60 pt-4" onSubmit={handleSubmit}>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="allocation-container">Behälter</Label>
|
||||||
|
<Select
|
||||||
|
value={storageContainerId}
|
||||||
|
onValueChange={(value) => setStorageContainerId(value ?? "")}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="allocation-container">
|
||||||
|
<SelectValue placeholder="Lagerbehälter wählen" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{containers?.map((container) => (
|
||||||
|
<SelectItem key={container.id} value={container.id}>
|
||||||
|
{container.code}
|
||||||
|
{container.location ? ` · ${container.location}` : ""}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<Label htmlFor="allocation-amount">Menge (kg)</Label>
|
||||||
|
<Input
|
||||||
|
id="allocation-amount"
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
value={amount}
|
||||||
|
onChange={(event) => setAmount(event.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error ? (
|
||||||
|
<p role="alert" className="text-sm text-destructive">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
<Button type="submit" variant="outline" disabled={assignMutation.isPending}>
|
||||||
|
{assignMutation.isPending ? "Wird zugewiesen…" : "Behälter zuweisen"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import { QrCodeDialog } from "@/components/qr-code-dialog";
|
|||||||
import { DocumentUploadCard } from "@/components/document-upload-card";
|
import { DocumentUploadCard } from "@/components/document-upload-card";
|
||||||
import { EmptyState } from "@/components/empty-state";
|
import { EmptyState } from "@/components/empty-state";
|
||||||
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
|
import { HoneyBatchStorageCard } from "./honey-batch-storage-card";
|
||||||
|
import { HoneyBatchContainerAllocationsCard } from "./honey-batch-container-allocations-card";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
@@ -83,6 +84,7 @@ export default function HoneyBatchDetailPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
|
<HoneyBatchStorageCard honeyBatch={honeyBatch} />
|
||||||
|
<HoneyBatchContainerAllocationsCard honeyBatch={honeyBatch} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 lg:col-span-2">
|
<div className="flex flex-col gap-4 lg:col-span-2">
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
CreateHoneyBatchInput,
|
CreateHoneyBatchInput,
|
||||||
CreateHoneyMeasurementInput,
|
CreateHoneyMeasurementInput,
|
||||||
HoneyBatch,
|
HoneyBatch,
|
||||||
|
HoneyBatchContainerAllocation,
|
||||||
HoneyBatchRelocation,
|
HoneyBatchRelocation,
|
||||||
HoneyBatchStatus,
|
HoneyBatchStatus,
|
||||||
RelocateHoneyBatchInput,
|
RelocateHoneyBatchInput,
|
||||||
@@ -69,3 +70,29 @@ export function relocateHoneyBatch(
|
|||||||
export function fetchHoneyBatchRelocations(id: string): Promise<HoneyBatchRelocation[]> {
|
export function fetchHoneyBatchRelocations(id: string): Promise<HoneyBatchRelocation[]> {
|
||||||
return apiFetch<HoneyBatchRelocation[]>(`/honey-batches/${id}/relocations`);
|
return apiFetch<HoneyBatchRelocation[]>(`/honey-batches/${id}/relocations`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function fetchHoneyBatchContainerAllocations(
|
||||||
|
id: string,
|
||||||
|
): Promise<HoneyBatchContainerAllocation[]> {
|
||||||
|
return apiFetch<HoneyBatchContainerAllocation[]>(`/honey-batches/${id}/containers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setHoneyBatchContainerAllocation(
|
||||||
|
id: string,
|
||||||
|
containerId: string,
|
||||||
|
amount: number,
|
||||||
|
): Promise<HoneyBatchContainerAllocation> {
|
||||||
|
return apiFetch<HoneyBatchContainerAllocation>(
|
||||||
|
`/honey-batches/${id}/containers/${containerId}`,
|
||||||
|
{ method: "PUT", body: JSON.stringify({ amount }) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeHoneyBatchContainerAllocation(
|
||||||
|
id: string,
|
||||||
|
containerId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
return apiFetch<void>(`/honey-batches/${id}/containers/${containerId}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -37,6 +37,14 @@ export interface HoneyBatch {
|
|||||||
measurements: HoneyMeasurement[];
|
measurements: HoneyMeasurement[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface HoneyBatchContainerAllocation {
|
||||||
|
id: string;
|
||||||
|
honeyBatchId: string;
|
||||||
|
storageContainerId: string;
|
||||||
|
storageContainer: StorageContainerRef;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface HoneyBatchRelocation {
|
export interface HoneyBatchRelocation {
|
||||||
id: string;
|
id: string;
|
||||||
fromStorageContainerId: string | null;
|
fromStorageContainerId: string | null;
|
||||||
|
|||||||
Reference in new issue
Block a user