feat(backend): Lagerbehälter-Umlagerung für Honigchargen (Modul 32)
Neues HoneyBatchRelocation-Modell dokumentiert jede Umlagerung einer Honigcharge zwischen Lagerbehältern (von/nach, Zeitpunkt, Benutzer, Notiz). HoneyBatchesService.relocate() aktualisiert HoneyBatch.storageContainerId transaktional zusammen mit dem Historieneintrag; wirft NotFoundException bei unbekanntem Zielbehälter. Neue Endpunkte: POST /honey-batches/:id/relocate, GET /honey-batches/:id/relocations. honeyBatchInclude liefert jetzt zusätzlich den aktuell zugeordneten storageContainer mit. Unit- und E2E-Tests ergänzt (Mehrfachumlagerung, Historienreihenfolge, 404 bei unbekanntem Behälter). Migration auf Dev- und Test-Datenbank angewendet.
This commit is contained in:
1 parent
fa13543578
commit
59a320ae36
7 files changed
+328
-44
No files matched your search
+28
@@ -0,0 +1,28 @@
|
||||
-- CreateTable
|
||||
CREATE TABLE "HoneyBatchRelocation" (
|
||||
"id" TEXT NOT NULL,
|
||||
"honeyBatchId" TEXT NOT NULL,
|
||||
"fromStorageContainerId" TEXT,
|
||||
"toStorageContainerId" TEXT NOT NULL,
|
||||
"userId" TEXT,
|
||||
"notes" TEXT,
|
||||
"relocatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "HoneyBatchRelocation_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "HoneyBatchRelocation_honeyBatchId_relocatedAt_idx" ON "HoneyBatchRelocation"("honeyBatchId", "relocatedAt");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_honeyBatchId_fkey" FOREIGN KEY ("honeyBatchId") REFERENCES "HoneyBatch"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_fromStorageContainerId_fkey" FOREIGN KEY ("fromStorageContainerId") REFERENCES "StorageContainer"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_toStorageContainerId_fkey" FOREIGN KEY ("toStorageContainerId") REFERENCES "StorageContainer"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "HoneyBatchRelocation" ADD CONSTRAINT "HoneyBatchRelocation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
@@ -37,23 +37,24 @@ model User {
|
||||
|
||||
refreshTokens RefreshToken[]
|
||||
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests Harvest[]
|
||||
extractions Extraction[]
|
||||
honeyMeasurements HoneyMeasurement[]
|
||||
stockMovements StockMovement[]
|
||||
assignedTasks Task[] @relation("TaskAssignee")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
colonyMoves ColonyApiaryHistory[]
|
||||
auditLogs AuditLog[]
|
||||
bottlingBatches BottlingBatch[]
|
||||
notifications Notification[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
webhookSubscriptions WebhookSubscription[]
|
||||
documents Document[]
|
||||
inspections Inspection[]
|
||||
varroaMeasurements VarroaMeasurement[]
|
||||
treatments Treatment[]
|
||||
feedings Feeding[]
|
||||
harvests Harvest[]
|
||||
extractions Extraction[]
|
||||
honeyMeasurements HoneyMeasurement[]
|
||||
stockMovements StockMovement[]
|
||||
assignedTasks Task[] @relation("TaskAssignee")
|
||||
createdTasks Task[] @relation("TaskCreator")
|
||||
colonyMoves ColonyApiaryHistory[]
|
||||
auditLogs AuditLog[]
|
||||
bottlingBatches BottlingBatch[]
|
||||
notifications Notification[]
|
||||
queenEvaluations QueenEvaluation[]
|
||||
webhookSubscriptions WebhookSubscription[]
|
||||
documents Document[]
|
||||
honeyBatchRelocations HoneyBatchRelocation[]
|
||||
|
||||
@@index([role])
|
||||
}
|
||||
@@ -266,22 +267,22 @@ model Queen {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
model QueenEvaluation {
|
||||
id String @id @default(cuid())
|
||||
queenId String
|
||||
queen Queen @relation(fields: [queenId], references: [id])
|
||||
colonyId String?
|
||||
colony Colony? @relation(fields: [colonyId], references: [id])
|
||||
date DateTime
|
||||
colonyStrength Int
|
||||
gentleness Int
|
||||
combBuild Int
|
||||
swarmingTendency Int
|
||||
broodPattern Int
|
||||
honeyYield Int
|
||||
notes String?
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
id String @id @default(cuid())
|
||||
queenId String
|
||||
queen Queen @relation(fields: [queenId], references: [id])
|
||||
colonyId String?
|
||||
colony Colony? @relation(fields: [colonyId], references: [id])
|
||||
date DateTime
|
||||
colonyStrength Int
|
||||
gentleness Int
|
||||
combBuild Int
|
||||
swarmingTendency Int
|
||||
broodPattern Int
|
||||
honeyYield Int
|
||||
notes String?
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([queenId])
|
||||
@@index([colonyId])
|
||||
@@ -563,6 +564,7 @@ model HoneyBatch {
|
||||
|
||||
measurements HoneyMeasurement[]
|
||||
bottlingBatches BottlingBatch[]
|
||||
relocations HoneyBatchRelocation[]
|
||||
|
||||
@@index([status])
|
||||
@@index([extractionId])
|
||||
@@ -608,11 +610,31 @@ model StorageContainer {
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
honeyBatches HoneyBatch[]
|
||||
honeyBatches HoneyBatch[]
|
||||
relocationsFrom HoneyBatchRelocation[] @relation("RelocationFrom")
|
||||
relocationsTo HoneyBatchRelocation[] @relation("RelocationTo")
|
||||
|
||||
@@index([status])
|
||||
}
|
||||
|
||||
// Umlagerungshistorie einer Honigcharge zwischen Lagerbehältern (Abschnitt 26, v1.x Modul 32)
|
||||
model HoneyBatchRelocation {
|
||||
id String @id @default(cuid())
|
||||
honeyBatchId String
|
||||
honeyBatch HoneyBatch @relation(fields: [honeyBatchId], references: [id])
|
||||
fromStorageContainerId String?
|
||||
fromStorageContainer StorageContainer? @relation("RelocationFrom", fields: [fromStorageContainerId], references: [id])
|
||||
toStorageContainerId String
|
||||
toStorageContainer StorageContainer @relation("RelocationTo", fields: [toStorageContainerId], references: [id])
|
||||
userId String?
|
||||
user User? @relation(fields: [userId], references: [id])
|
||||
notes String?
|
||||
relocatedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([honeyBatchId, relocatedAt])
|
||||
}
|
||||
|
||||
enum StockCategory {
|
||||
HONEY
|
||||
CONSUMABLE
|
||||
@@ -669,16 +691,16 @@ enum ProductStatus {
|
||||
}
|
||||
|
||||
model Product {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
sku String? @unique
|
||||
packagingSize Float?
|
||||
weight Float?
|
||||
salesUnit String?
|
||||
status ProductStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
sku String? @unique
|
||||
packagingSize Float?
|
||||
weight Float?
|
||||
salesUnit String?
|
||||
status ProductStatus @default(ACTIVE)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
bottlingBatches BottlingBatch[]
|
||||
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ApiPropertyOptional, ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class RelocateHoneyBatchDto {
|
||||
@ApiProperty()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
storageContainerId!: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
import { HoneyBatchesService } from './honey-batches.service';
|
||||
|
||||
@ApiTags('honey-batches')
|
||||
@@ -29,6 +30,21 @@ export class HoneyBatchesController {
|
||||
return this.honeyBatchesService.traceability(id);
|
||||
}
|
||||
|
||||
@Get(':id/relocations')
|
||||
findRelocations(@Param('id') id: string) {
|
||||
return this.honeyBatchesService.findRelocations(id);
|
||||
}
|
||||
|
||||
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
|
||||
@Post(':id/relocate')
|
||||
relocate(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RelocateHoneyBatchDto,
|
||||
@CurrentUser() actor: AuthenticatedUser,
|
||||
) {
|
||||
return this.honeyBatchesService.relocate(id, dto, actor.id);
|
||||
}
|
||||
|
||||
@Roles(Role.ADMIN, Role.BEEKEEPER)
|
||||
@Post()
|
||||
create(
|
||||
|
||||
@@ -11,8 +11,12 @@ describe('HoneyBatchesService', () => {
|
||||
findMany: jest.Mock;
|
||||
findUnique: jest.Mock;
|
||||
create: jest.Mock;
|
||||
update: jest.Mock;
|
||||
};
|
||||
honeyMeasurement: { create: jest.Mock };
|
||||
storageContainer: { findUnique: jest.Mock };
|
||||
honeyBatchRelocation: { create: jest.Mock; findMany: jest.Mock };
|
||||
$transaction: jest.Mock;
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
|
||||
@@ -47,8 +51,12 @@ describe('HoneyBatchesService', () => {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn(),
|
||||
create: jest.fn(),
|
||||
update: jest.fn(),
|
||||
},
|
||||
honeyMeasurement: { create: jest.fn() },
|
||||
storageContainer: { findUnique: jest.fn() },
|
||||
honeyBatchRelocation: { create: jest.fn(), findMany: jest.fn() },
|
||||
$transaction: jest.fn(),
|
||||
};
|
||||
auditLog = { record: jest.fn() };
|
||||
const webhooks = { dispatch: jest.fn().mockResolvedValue(undefined) };
|
||||
@@ -98,4 +106,94 @@ describe('HoneyBatchesService', () => {
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
describe('relocate', () => {
|
||||
beforeEach(() => {
|
||||
prisma.$transaction.mockImplementation(
|
||||
async (
|
||||
callback: (tx: typeof prisma) => Promise<unknown>,
|
||||
): Promise<unknown> => callback(prisma),
|
||||
);
|
||||
});
|
||||
|
||||
it('throws NotFoundException when the target container does not exist', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue({
|
||||
...honeyBatch,
|
||||
storageContainerId: null,
|
||||
});
|
||||
prisma.storageContainer.findUnique.mockResolvedValue(null);
|
||||
|
||||
await expect(
|
||||
service.relocate(
|
||||
'batch-1',
|
||||
{ storageContainerId: 'missing' },
|
||||
'user-1',
|
||||
),
|
||||
).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('records the relocation and updates the batch container', async () => {
|
||||
prisma.honeyBatch.findUnique
|
||||
.mockResolvedValueOnce({
|
||||
...honeyBatch,
|
||||
storageContainerId: 'container-old',
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
...honeyBatch,
|
||||
storageContainerId: 'container-new',
|
||||
});
|
||||
prisma.storageContainer.findUnique.mockResolvedValue({
|
||||
id: 'container-new',
|
||||
});
|
||||
prisma.honeyBatchRelocation.create.mockResolvedValue({
|
||||
id: 'relocation-1',
|
||||
honeyBatchId: 'batch-1',
|
||||
fromStorageContainerId: 'container-old',
|
||||
toStorageContainerId: 'container-new',
|
||||
});
|
||||
|
||||
await service.relocate(
|
||||
'batch-1',
|
||||
{ storageContainerId: 'container-new', notes: 'Umzug ins Lager 2' },
|
||||
'user-1',
|
||||
);
|
||||
|
||||
const [[createArg]] = prisma.honeyBatchRelocation.create.mock.calls as [
|
||||
[
|
||||
{
|
||||
data: {
|
||||
fromStorageContainerId: string | null;
|
||||
toStorageContainerId: string;
|
||||
};
|
||||
},
|
||||
],
|
||||
];
|
||||
expect(createArg.data.fromStorageContainerId).toBe('container-old');
|
||||
expect(createArg.data.toStorageContainerId).toBe('container-new');
|
||||
const [[updateArg]] = prisma.honeyBatch.update.mock.calls as [
|
||||
[{ data: { storageContainerId: string } }],
|
||||
];
|
||||
expect(updateArg.data.storageContainerId).toBe('container-new');
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entityType: 'HoneyBatchRelocation' }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findRelocations', () => {
|
||||
it('returns the relocation history ordered by date', async () => {
|
||||
prisma.honeyBatch.findUnique.mockResolvedValue(honeyBatch);
|
||||
prisma.honeyBatchRelocation.findMany.mockResolvedValue([
|
||||
{ id: 'relocation-1', honeyBatchId: 'batch-1' },
|
||||
]);
|
||||
|
||||
const result = await service.findRelocations('batch-1');
|
||||
|
||||
expect(result).toEqual([{ id: 'relocation-1', honeyBatchId: 'batch-1' }]);
|
||||
const [[findArg]] = prisma.honeyBatchRelocation.findMany.mock.calls as [
|
||||
[{ where: { honeyBatchId: string } }],
|
||||
];
|
||||
expect(findArg.where.honeyBatchId).toBe('batch-1');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,9 +5,11 @@ import { WebhooksService } from '../webhooks/webhooks.service';
|
||||
import { HoneyBatchStatus } from '../../generated/prisma';
|
||||
import { CreateHoneyBatchDto } from './dto/create-honey-batch.dto';
|
||||
import { CreateHoneyMeasurementDto } from './dto/create-honey-measurement.dto';
|
||||
import { RelocateHoneyBatchDto } from './dto/relocate-honey-batch.dto';
|
||||
|
||||
const honeyBatchInclude = {
|
||||
measurements: true,
|
||||
storageContainer: true,
|
||||
extraction: {
|
||||
include: {
|
||||
harvests: {
|
||||
@@ -90,6 +92,50 @@ export class HoneyBatchesService {
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async relocate(id: string, dto: RelocateHoneyBatchDto, actingUserId: string) {
|
||||
const honeyBatch = await this.findById(id);
|
||||
const targetContainer = await this.prisma.storageContainer.findUnique({
|
||||
where: { id: dto.storageContainerId },
|
||||
});
|
||||
if (!targetContainer) {
|
||||
throw new NotFoundException('Storage container not found');
|
||||
}
|
||||
|
||||
const relocation = await this.prisma.$transaction(async (tx) => {
|
||||
const created = await tx.honeyBatchRelocation.create({
|
||||
data: {
|
||||
honeyBatchId: id,
|
||||
fromStorageContainerId: honeyBatch.storageContainerId,
|
||||
toStorageContainerId: dto.storageContainerId,
|
||||
userId: actingUserId,
|
||||
notes: dto.notes,
|
||||
},
|
||||
});
|
||||
await tx.honeyBatch.update({
|
||||
where: { id },
|
||||
data: { storageContainerId: dto.storageContainerId },
|
||||
});
|
||||
return created;
|
||||
});
|
||||
await this.auditLog.record({
|
||||
entityType: 'HoneyBatchRelocation',
|
||||
entityId: relocation.id,
|
||||
action: 'CREATE',
|
||||
userId: actingUserId,
|
||||
afterData: relocation,
|
||||
});
|
||||
return this.findById(id);
|
||||
}
|
||||
|
||||
async findRelocations(id: string) {
|
||||
await this.findById(id);
|
||||
return this.prisma.honeyBatchRelocation.findMany({
|
||||
where: { honeyBatchId: id },
|
||||
include: { fromStorageContainer: true, toStorageContainer: true },
|
||||
orderBy: { relocatedAt: 'desc' },
|
||||
});
|
||||
}
|
||||
|
||||
async traceability(id: string) {
|
||||
const honeyBatch = await this.findById(id);
|
||||
return {
|
||||
|
||||
@@ -10,6 +10,13 @@ interface HoneyBatchBody {
|
||||
id: string;
|
||||
batchNumber: string;
|
||||
measurements: { id: string }[];
|
||||
storageContainerId: string | null;
|
||||
}
|
||||
|
||||
interface RelocationBody {
|
||||
id: string;
|
||||
fromStorageContainerId: string | null;
|
||||
toStorageContainerId: string;
|
||||
}
|
||||
|
||||
interface TraceabilityBody {
|
||||
@@ -33,6 +40,8 @@ describe('Honey batches (e2e)', () => {
|
||||
let harvestId: string;
|
||||
let extractionId: string;
|
||||
let honeyBatchId: string;
|
||||
let containerAId: string;
|
||||
let containerBId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await createTestApp();
|
||||
@@ -79,13 +88,27 @@ describe('Honey batches (e2e)', () => {
|
||||
await prisma.extractionHarvest.create({
|
||||
data: { extractionId, harvestId },
|
||||
});
|
||||
const containerA = await prisma.storageContainer.create({
|
||||
data: { code: `LB-A-${Date.now()}` },
|
||||
});
|
||||
containerAId = containerA.id;
|
||||
const containerB = await prisma.storageContainer.create({
|
||||
data: { code: `LB-B-${Date.now()}` },
|
||||
});
|
||||
containerBId = containerB.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (honeyBatchId) {
|
||||
await prisma.honeyBatchRelocation.deleteMany({
|
||||
where: { honeyBatchId },
|
||||
});
|
||||
await prisma.honeyMeasurement.deleteMany({ where: { honeyBatchId } });
|
||||
await prisma.honeyBatch.deleteMany({ where: { id: honeyBatchId } });
|
||||
}
|
||||
await prisma.storageContainer.deleteMany({
|
||||
where: { id: { in: [containerAId, containerBId] } },
|
||||
});
|
||||
await prisma.extractionHarvest.deleteMany({ where: { extractionId } });
|
||||
await prisma.extraction.deleteMany({ where: { id: extractionId } });
|
||||
await prisma.harvestColony.deleteMany({ where: { harvestId } });
|
||||
@@ -142,4 +165,41 @@ describe('Honey batches (e2e)', () => {
|
||||
expect(trace.extraction.id).toBe(extractionId);
|
||||
expect(trace.harvests[0]?.colonies[0]?.code).toBeDefined();
|
||||
});
|
||||
|
||||
it('relocates the honey batch into a storage container', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post(`/api/v1/honey-batches/${honeyBatchId}/relocate`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ storageContainerId: containerAId, notes: 'Erstbefüllung' })
|
||||
.expect(201);
|
||||
const honeyBatch = response.body as HoneyBatchBody;
|
||||
expect(honeyBatch.storageContainerId).toBe(containerAId);
|
||||
});
|
||||
|
||||
it('rejects relocation to a nonexistent storage container', () => {
|
||||
return request(app.getHttpServer())
|
||||
.post(`/api/v1/honey-batches/${honeyBatchId}/relocate`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ storageContainerId: 'does-not-exist' })
|
||||
.expect(404);
|
||||
});
|
||||
|
||||
it('relocates the honey batch again and records both moves in history', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post(`/api/v1/honey-batches/${honeyBatchId}/relocate`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ storageContainerId: containerBId })
|
||||
.expect(201);
|
||||
|
||||
const response = await request(app.getHttpServer())
|
||||
.get(`/api/v1/honey-batches/${honeyBatchId}/relocations`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.expect(200);
|
||||
const relocations = response.body as RelocationBody[];
|
||||
expect(relocations).toHaveLength(2);
|
||||
expect(relocations[0].toStorageContainerId).toBe(containerBId);
|
||||
expect(relocations[0].fromStorageContainerId).toBe(containerAId);
|
||||
expect(relocations[1].toStorageContainerId).toBe(containerAId);
|
||||
expect(relocations[1].fromStorageContainerId).toBeNull();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user