diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts
index a3aff13..566ebf5 100644
--- a/ApiCore-Backend/src/app.module.ts
+++ b/ApiCore-Backend/src/app.module.ts
@@ -32,6 +32,7 @@ import { WebhooksModule } from './webhooks/webhooks.module';
import { DocumentsModule } from './documents/documents.module';
import { AutomationModule } from './automation/automation.module';
import { ImportModule } from './import/import.module';
+import { PublicModule } from './public/public.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@@ -69,6 +70,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
DocumentsModule,
AutomationModule,
ImportModule,
+ PublicModule,
],
controllers: [],
providers: [
diff --git a/ApiCore-Backend/src/public/public.controller.ts b/ApiCore-Backend/src/public/public.controller.ts
new file mode 100644
index 0000000..81680b5
--- /dev/null
+++ b/ApiCore-Backend/src/public/public.controller.ts
@@ -0,0 +1,16 @@
+import { Controller, Get, Param } from '@nestjs/common';
+import { ApiTags } from '@nestjs/swagger';
+import { Public } from '../auth/decorators/public.decorator';
+import { PublicBatch, PublicService } from './public.service';
+
+@ApiTags('public')
+@Public()
+@Controller('public/batches')
+export class PublicController {
+ constructor(private readonly publicService: PublicService) {}
+
+ @Get(':lotNumber')
+ findByLot(@Param('lotNumber') lotNumber: string): Promise {
+ return this.publicService.findBatchByLot(lotNumber);
+ }
+}
diff --git a/ApiCore-Backend/src/public/public.module.ts b/ApiCore-Backend/src/public/public.module.ts
new file mode 100644
index 0000000..e0fbe03
--- /dev/null
+++ b/ApiCore-Backend/src/public/public.module.ts
@@ -0,0 +1,9 @@
+import { Module } from '@nestjs/common';
+import { PublicController } from './public.controller';
+import { PublicService } from './public.service';
+
+@Module({
+ controllers: [PublicController],
+ providers: [PublicService],
+})
+export class PublicModule {}
diff --git a/ApiCore-Backend/src/public/public.service.spec.ts b/ApiCore-Backend/src/public/public.service.spec.ts
new file mode 100644
index 0000000..36a015b
--- /dev/null
+++ b/ApiCore-Backend/src/public/public.service.spec.ts
@@ -0,0 +1,69 @@
+import { NotFoundException } from '@nestjs/common';
+import { PublicService } from './public.service';
+import { PrismaService } from '../prisma/prisma.service';
+
+describe('PublicService', () => {
+ let service: PublicService;
+ let prisma: { bottlingBatch: { findUnique: jest.Mock } };
+
+ beforeEach(() => {
+ prisma = { bottlingBatch: { findUnique: jest.fn() } };
+ service = new PublicService(prisma as unknown as PrismaService);
+ });
+
+ it('throws NotFoundException when the lot does not exist', async () => {
+ prisma.bottlingBatch.findUnique.mockResolvedValueOnce(null);
+ await expect(service.findBatchByLot('missing')).rejects.toBeInstanceOf(
+ NotFoundException,
+ );
+ });
+
+ it('shapes the traceability chain into public-safe fields, deduplicating apiaries', async () => {
+ const harvestDate1 = new Date('2026-08-05');
+ const harvestDate2 = new Date('2026-08-12');
+ prisma.bottlingBatch.findUnique.mockResolvedValueOnce({
+ lotNumber: 'WH-2026-001',
+ date: new Date('2026-08-20'),
+ bestBeforeDate: new Date('2028-08-20'),
+ jarSize: 500,
+ product: { name: 'Sommerhonig 500g', description: 'Sortenrein' },
+ honeyBatch: {
+ honeyType: 'Sommerhonig',
+ waterContent: 16.8,
+ extraction: {
+ date: new Date('2026-08-13'),
+ harvests: [
+ {
+ harvest: {
+ date: harvestDate1,
+ apiary: {
+ id: 'apiary-1',
+ name: 'Stand Wald',
+ location: 'Wald',
+ },
+ },
+ },
+ {
+ harvest: {
+ date: harvestDate2,
+ apiary: {
+ id: 'apiary-1',
+ name: 'Stand Wald',
+ location: 'Wald',
+ },
+ },
+ },
+ ],
+ },
+ },
+ });
+
+ const result = await service.findBatchByLot('WH-2026-001');
+
+ expect(result.lotNumber).toBe('WH-2026-001');
+ expect(result.productName).toBe('Sommerhonig 500g');
+ expect(result.waterContent).toBe(16.8);
+ expect(result.apiaries).toEqual([{ name: 'Stand Wald', location: 'Wald' }]);
+ expect(result.harvestDates).toEqual([harvestDate1, harvestDate2]);
+ });
+});
diff --git a/ApiCore-Backend/src/public/public.service.ts b/ApiCore-Backend/src/public/public.service.ts
new file mode 100644
index 0000000..a66bca8
--- /dev/null
+++ b/ApiCore-Backend/src/public/public.service.ts
@@ -0,0 +1,77 @@
+import { Injectable, NotFoundException } from '@nestjs/common';
+import { PrismaService } from '../prisma/prisma.service';
+
+export interface PublicApiaryOrigin {
+ name: string;
+ location: string | null;
+}
+
+export interface PublicBatch {
+ lotNumber: string;
+ productName: string | null;
+ productDescription: string | null;
+ bottledDate: Date;
+ bestBeforeDate: Date;
+ jarSize: number;
+ honeyType: string | null;
+ waterContent: number | null;
+ extractedDate: Date | null;
+ harvestDates: Date[];
+ apiaries: PublicApiaryOrigin[];
+}
+
+@Injectable()
+export class PublicService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ async findBatchByLot(lotNumber: string): Promise {
+ const batch = await this.prisma.bottlingBatch.findUnique({
+ where: { lotNumber },
+ include: {
+ product: true,
+ honeyBatch: {
+ include: {
+ extraction: {
+ include: {
+ harvests: {
+ include: { harvest: { include: { apiary: true } } },
+ },
+ },
+ },
+ },
+ },
+ },
+ });
+ if (!batch) {
+ throw new NotFoundException('Charge nicht gefunden');
+ }
+
+ const harvests = batch.honeyBatch.extraction.harvests.map(
+ (entry) => entry.harvest,
+ );
+ const apiaryByName = new Map();
+ for (const harvest of harvests) {
+ apiaryByName.set(harvest.apiary.id, {
+ name: harvest.apiary.name,
+ location: harvest.apiary.location,
+ });
+ }
+ const harvestDates = [...new Set(harvests.map((h) => h.date.getTime()))]
+ .sort((a, b) => a - b)
+ .map((time) => new Date(time));
+
+ return {
+ lotNumber: batch.lotNumber,
+ productName: batch.product?.name ?? null,
+ productDescription: batch.product?.description ?? null,
+ bottledDate: batch.date,
+ bestBeforeDate: batch.bestBeforeDate,
+ jarSize: batch.jarSize,
+ honeyType: batch.honeyBatch.honeyType,
+ waterContent: batch.honeyBatch.waterContent,
+ extractedDate: batch.honeyBatch.extraction.date,
+ harvestDates,
+ apiaries: [...apiaryByName.values()],
+ };
+ }
+}
diff --git a/ApiCore-Backend/src/qr/qr.service.spec.ts b/ApiCore-Backend/src/qr/qr.service.spec.ts
index eff6bbd..c5f2ebc 100644
--- a/ApiCore-Backend/src/qr/qr.service.spec.ts
+++ b/ApiCore-Backend/src/qr/qr.service.spec.ts
@@ -10,6 +10,7 @@ describe('QrService', () => {
queen: { findUnique: jest.Mock };
honeyBatch: { findUnique: jest.Mock };
storageContainer: { findUnique: jest.Mock };
+ bottlingBatch: { findUnique: jest.Mock };
};
beforeEach(() => {
@@ -19,6 +20,7 @@ describe('QrService', () => {
queen: { findUnique: jest.fn() },
honeyBatch: { findUnique: jest.fn() },
storageContainer: { findUnique: jest.fn() },
+ bottlingBatch: { findUnique: jest.fn() },
};
service = new QrService(prisma as unknown as PrismaService);
});
@@ -47,4 +49,12 @@ describe('QrService', () => {
const svg = await service.generateSvg('storage-containers', 'sc-1');
expect(svg).toContain('
+
+ Öffentliche Herkunftsseite:{" "}
+
+ /herkunft/{bottlingBatch.lotNumber}
+
+
diff --git a/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx b/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx
new file mode 100644
index 0000000..3466b67
--- /dev/null
+++ b/ApiCore-Frontend/src/app/herkunft/[lot]/page.tsx
@@ -0,0 +1,116 @@
+"use client";
+
+import { useParams } from "next/navigation";
+import { useQuery } from "@tanstack/react-query";
+import { Hexagon, Droplet, MapPin, CalendarDays } from "lucide-react";
+import { fetchPublicBatch } from "@/lib/public-batch/api";
+import { HoneycombPattern } from "@/components/honeycomb-pattern";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+
+function formatDate(iso: string): string {
+ return new Date(iso).toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit", year: "numeric" });
+}
+
+function formatMonthYear(iso: string): string {
+ return new Date(iso).toLocaleDateString("de-DE", { month: "long", year: "numeric" });
+}
+
+export default function PublicBatchPage() {
+ const params = useParams<{ lot: string }>();
+ const lot = decodeURIComponent(params.lot);
+
+ const { data: batch, isLoading, isError } = useQuery({
+ queryKey: ["public-batch", lot],
+ queryFn: () => fetchPublicBatch(lot),
+ retry: false,
+ });
+
+ const harvestMonths = batch
+ ? [...new Set(batch.harvestDates.map(formatMonthYear))].join(", ")
+ : null;
+ const apiaryNames = batch ? batch.apiaries.map((a) => a.name).join(", ") : null;
+
+ return (
+
+
+
+
+
+
+
+
+
+
Herkunftsnachweis
+
Wabenhain · ApiCore
+
+
+
+
+ {isLoading ? Lädt…
: null}
+ {isError ? (
+
+ Keine Charge mit dieser Nummer gefunden.
+
+ ) : null}
+ {batch ? (
+ <>
+
+
+ {batch.productName ?? "Honig"}
+
+
Charge {batch.lotNumber}
+
+ {batch.productDescription ? (
+ {batch.productDescription}
+ ) : null}
+
+ {apiaryNames ? (
+
+
+
+
- Herkunft
+ - {apiaryNames}
+
+
+ ) : null}
+ {harvestMonths ? (
+
+
+
+
- Geerntet
+ - {harvestMonths}
+
+
+ ) : null}
+ {batch.extractedDate ? (
+
+
+
+
- Geschleudert
+ - {formatDate(batch.extractedDate)}
+
+
+ ) : null}
+ {batch.waterContent !== null ? (
+
+
+
+
- Wassergehalt
+ - {batch.waterContent} %
+
+
+ ) : null}
+
+
+ Mindestens haltbar bis {formatDate(batch.bestBeforeDate)} · {batch.jarSize} g Gebinde
+
+ >
+ ) : null}
+
+
+
+ );
+}
diff --git a/ApiCore-Frontend/src/lib/public-batch/api.ts b/ApiCore-Frontend/src/lib/public-batch/api.ts
new file mode 100644
index 0000000..05740e1
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/public-batch/api.ts
@@ -0,0 +1,6 @@
+import { apiFetch } from "@/lib/auth/api-client";
+import { PublicBatch } from "./types";
+
+export function fetchPublicBatch(lotNumber: string): Promise {
+ return apiFetch(`/public/batches/${encodeURIComponent(lotNumber)}`);
+}
diff --git a/ApiCore-Frontend/src/lib/public-batch/types.ts b/ApiCore-Frontend/src/lib/public-batch/types.ts
new file mode 100644
index 0000000..7bdd72a
--- /dev/null
+++ b/ApiCore-Frontend/src/lib/public-batch/types.ts
@@ -0,0 +1,18 @@
+export interface PublicApiaryOrigin {
+ name: string;
+ location: string | null;
+}
+
+export interface PublicBatch {
+ lotNumber: string;
+ productName: string | null;
+ productDescription: string | null;
+ bottledDate: string;
+ bestBeforeDate: string;
+ jarSize: number;
+ honeyType: string | null;
+ waterContent: number | null;
+ extractedDate: string | null;
+ harvestDates: string[];
+ apiaries: PublicApiaryOrigin[];
+}
diff --git a/ApiCore-Frontend/src/lib/qr/api.ts b/ApiCore-Frontend/src/lib/qr/api.ts
index 5741f9f..16162d4 100644
--- a/ApiCore-Frontend/src/lib/qr/api.ts
+++ b/ApiCore-Frontend/src/lib/qr/api.ts
@@ -5,7 +5,8 @@ export type QrEntityType =
| "apiaries"
| "queens"
| "honey-batches"
- | "storage-containers";
+ | "storage-containers"
+ | "bottling-batches";
export async function fetchQrCodeSvg(entityType: QrEntityType, id: string): Promise {
const { blob } = await apiFetchBlob(`/qr/${entityType}/${id}.svg`, `${entityType}.svg`);