From ce9cea1b941c48cad964070c88b34df5517bd73d Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Wed, 26 Aug 2026 09:43:08 +0200 Subject: [PATCH] feat: Wetterintegration + Trachtprognose pro Bienenstand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neues WeatherModule: liest Bienenstand-Koordinaten und ruft die freie Open-Meteo-API ab (kein API-Key nötig), liefert 14 Tage Wetterhistorie + 7-Tage-Vorhersage sowie eine heuristische Trachtprognose (Saison + Wetter der letzten 7 Tage + historischer Ernteschnitt für denselben Kalendermonat am selben Standort - explizit kein ML-Modell, siehe TASKS.md Abschnitt 26). - GET /api/v1/apiaries/:id/weather, GET .../trachtprognose, beide ueber die bestehende apiaries:read-Berechtigung abgesichert (kein neuer Permission-Key nötig, gleiches Muster wie die bestehenden Colony-Sub-Controller). - Bienenstände ohne hinterlegte Koordinaten liefern 400 statt eines stillen Fallbacks. - test/weather.e2e-spec.ts ruft bewusst die echte Open-Meteo-API auf (kostenlos, kein Auth, stabil genug für einen einzelnen Testlauf) - validiert damit den echten Response-Vertrag statt nur ein Mock. - TASKS.md: beide Punkte abgehakt, stale Duplikat-Zeilen im Später-Abschnitt bereinigt. Verifiziert: tsc, lint, build, unit (228/228), volle e2e-Suite 163/163 gruen. Co-Authored-By: Claude Sonnet 5 --- ApiCore-Backend/src/app.module.ts | 2 + .../src/weather/weather.controller.ts | 23 ++ ApiCore-Backend/src/weather/weather.module.ts | 9 + .../src/weather/weather.service.spec.ts | 202 ++++++++++++++++ .../src/weather/weather.service.ts | 215 ++++++++++++++++++ ApiCore-Backend/test/weather.e2e-spec.ts | 117 ++++++++++ TASKS.md | 7 - 7 files changed, 568 insertions(+), 7 deletions(-) create mode 100644 ApiCore-Backend/src/weather/weather.controller.ts create mode 100644 ApiCore-Backend/src/weather/weather.module.ts create mode 100644 ApiCore-Backend/src/weather/weather.service.spec.ts create mode 100644 ApiCore-Backend/src/weather/weather.service.ts create mode 100644 ApiCore-Backend/test/weather.e2e-spec.ts diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index c737c6d..6039478 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -37,6 +37,7 @@ import { ImportModule } from './import/import.module'; import { PublicModule } from './public/public.module'; import { TaskTemplatesModule } from './task-templates/task-templates.module'; import { SwarmControlModule } from './swarm-control/swarm-control.module'; +import { WeatherModule } from './weather/weather.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { PermissionsGuard } from './auth/guards/permissions.guard'; import { PermissionsModule } from './auth/permissions/permissions.module'; @@ -81,6 +82,7 @@ import { PermissionsModule } from './auth/permissions/permissions.module'; PublicModule, TaskTemplatesModule, SwarmControlModule, + WeatherModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/weather/weather.controller.ts b/ApiCore-Backend/src/weather/weather.controller.ts new file mode 100644 index 0000000..6f93e5d --- /dev/null +++ b/ApiCore-Backend/src/weather/weather.controller.ts @@ -0,0 +1,23 @@ +import { Controller, Get, Param } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { RequirePermission } from '../auth/decorators/require-permission.decorator'; +import { WeatherService } from './weather.service'; + +@ApiTags('weather') +@ApiBearerAuth() +@Controller('apiaries') +export class WeatherController { + constructor(private readonly weatherService: WeatherService) {} + + @RequirePermission('apiaries:read') + @Get(':id/weather') + getWeather(@Param('id') id: string) { + return this.weatherService.getWeather(id); + } + + @RequirePermission('apiaries:read') + @Get(':id/trachtprognose') + getTrachtprognose(@Param('id') id: string) { + return this.weatherService.getTrachtprognose(id); + } +} diff --git a/ApiCore-Backend/src/weather/weather.module.ts b/ApiCore-Backend/src/weather/weather.module.ts new file mode 100644 index 0000000..e38614a --- /dev/null +++ b/ApiCore-Backend/src/weather/weather.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { WeatherController } from './weather.controller'; +import { WeatherService } from './weather.service'; + +@Module({ + controllers: [WeatherController], + providers: [WeatherService], +}) +export class WeatherModule {} diff --git a/ApiCore-Backend/src/weather/weather.service.spec.ts b/ApiCore-Backend/src/weather/weather.service.spec.ts new file mode 100644 index 0000000..0ae37f0 --- /dev/null +++ b/ApiCore-Backend/src/weather/weather.service.spec.ts @@ -0,0 +1,202 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { WeatherService } from './weather.service'; +import { PrismaService } from '../prisma/prisma.service'; + +function openMeteoResponse( + days: { + date: string; + tempMax: number | null; + tempMin: number | null; + precipitation: number | null; + }[], +): unknown { + return { + daily: { + time: days.map((day) => day.date), + temperature_2m_max: days.map((day) => day.tempMax), + temperature_2m_min: days.map((day) => day.tempMin), + precipitation_sum: days.map((day) => day.precipitation), + }, + }; +} + +describe('WeatherService', () => { + let weatherService: WeatherService; + let prisma: { + apiary: { findUnique: jest.Mock }; + harvest: { findMany: jest.Mock }; + }; + let fetchMock: jest.SpiedFunction; + + beforeEach(() => { + prisma = { + apiary: { findUnique: jest.fn() }, + harvest: { findMany: jest.fn().mockResolvedValue([]) }, + }; + weatherService = new WeatherService(prisma as unknown as PrismaService); + fetchMock = jest.spyOn(global, 'fetch'); + }); + + afterEach(() => { + fetchMock.mockRestore(); + }); + + describe('getWeather', () => { + it('throws NotFoundException when the apiary does not exist', async () => { + prisma.apiary.findUnique.mockResolvedValue(null); + await expect(weatherService.getWeather('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('throws BadRequestException when the apiary has no coordinates', async () => { + prisma.apiary.findUnique.mockResolvedValue({ + latitude: null, + longitude: null, + }); + await expect( + weatherService.getWeather('apiary-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + + it('returns daily weather mapped from the Open-Meteo response', async () => { + prisma.apiary.findUnique.mockResolvedValue({ + latitude: 52.5, + longitude: 13.4, + }); + fetchMock.mockResolvedValue( + new Response( + JSON.stringify( + openMeteoResponse([ + { + date: '2026-06-01', + tempMax: 22, + tempMin: 12, + precipitation: 0, + }, + ]), + ), + { status: 200 }, + ), + ); + + const result = await weatherService.getWeather('apiary-1'); + + expect(result.daily).toEqual([ + { date: '2026-06-01', tempMaxC: 22, tempMinC: 12, precipitationMm: 0 }, + ]); + const [url] = fetchMock.mock.calls[0] as [URL]; + expect(url.toString()).toContain('latitude=52.5'); + expect(url.toString()).toContain('longitude=13.4'); + }); + + it('throws BadRequestException when Open-Meteo responds with an error status', async () => { + prisma.apiary.findUnique.mockResolvedValue({ + latitude: 52.5, + longitude: 13.4, + }); + fetchMock.mockResolvedValue(new Response('', { status: 500 })); + + await expect( + weatherService.getWeather('apiary-1'), + ).rejects.toBeInstanceOf(BadRequestException); + }); + }); + + describe('getTrachtprognose', () => { + const realDate = Date; + + function mockToday(isoDate: string) { + const fixed = new realDate(isoDate); + jest + .spyOn(global, 'Date') + .mockImplementation((...args: unknown[]) => + args.length ? new realDate(...(args as [string])) : fixed, + ); + } + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('returns KEINE_TRACHT outside the bloom season', async () => { + mockToday('2026-01-15'); + prisma.apiary.findUnique.mockResolvedValue({ + latitude: 52.5, + longitude: 13.4, + }); + fetchMock.mockResolvedValue( + new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }), + ); + + const result = await weatherService.getTrachtprognose('apiary-1'); + + expect(result.rating).toBe('KEINE_TRACHT'); + expect(result.season).toBe('Winterruhe'); + }); + + it('returns GUT for warm, dry weather during bloom season', async () => { + mockToday('2026-06-10'); + prisma.apiary.findUnique.mockResolvedValue({ + latitude: 52.5, + longitude: 13.4, + }); + const days = Array.from({ length: 21 }, (_, index) => ({ + date: `2026-06-${String(index + 1).padStart(2, '0')}`, + tempMax: 24, + tempMin: 14, + precipitation: 0, + })); + fetchMock.mockResolvedValue( + new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }), + ); + + const result = await weatherService.getTrachtprognose('apiary-1'); + + expect(result.rating).toBe('GUT'); + expect(result.season).toBe('Sommertracht'); + }); + + it('returns SCHWACH for cold, wet weather during bloom season', async () => { + mockToday('2026-04-10'); + prisma.apiary.findUnique.mockResolvedValue({ + latitude: 52.5, + longitude: 13.4, + }); + const days = Array.from({ length: 21 }, (_, index) => ({ + date: `2026-04-${String(index + 1).padStart(2, '0')}`, + tempMax: 8, + tempMin: 2, + precipitation: 10, + })); + fetchMock.mockResolvedValue( + new Response(JSON.stringify(openMeteoResponse(days)), { status: 200 }), + ); + + const result = await weatherService.getTrachtprognose('apiary-1'); + + expect(result.rating).toBe('SCHWACH'); + expect(result.season).toBe('Frühtracht'); + }); + + it('includes the historical average yield when past harvests exist for the same month', async () => { + mockToday('2026-06-10'); + prisma.apiary.findUnique.mockResolvedValue({ + latitude: 52.5, + longitude: 13.4, + }); + prisma.harvest.findMany.mockResolvedValue([ + { date: new realDate('2025-06-15'), netWeight: 20 }, + { date: new realDate('2024-06-20'), netWeight: 30 }, + { date: new realDate('2025-09-01'), netWeight: 100 }, + ]); + fetchMock.mockResolvedValue( + new Response(JSON.stringify(openMeteoResponse([])), { status: 200 }), + ); + + const result = await weatherService.getTrachtprognose('apiary-1'); + + expect(result.historicalAverageYieldKg).toBe(25); + }); + }); +}); diff --git a/ApiCore-Backend/src/weather/weather.service.ts b/ApiCore-Backend/src/weather/weather.service.ts new file mode 100644 index 0000000..63ba5e9 --- /dev/null +++ b/ApiCore-Backend/src/weather/weather.service.ts @@ -0,0 +1,215 @@ +import { + BadRequestException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +const OPEN_METEO_FORECAST_URL = 'https://api.open-meteo.com/v1/forecast'; +const PAST_DAYS = 14; +const FORECAST_DAYS = 7; +const RECENT_WINDOW_DAYS = 7; +const GOOD_TEMP_THRESHOLD_C = 18; +const GOOD_PRECIPITATION_THRESHOLD_MM = 15; +const POOR_TEMP_THRESHOLD_C = 12; +const POOR_PRECIPITATION_THRESHOLD_MM = 30; + +export interface DailyWeather { + date: string; + tempMaxC: number | null; + tempMinC: number | null; + precipitationMm: number | null; +} + +export interface ApiaryWeather { + daily: DailyWeather[]; +} + +export type TrachtprognoseRating = + 'GUT' | 'MAESSIG' | 'SCHWACH' | 'KEINE_TRACHT'; + +export interface Trachtprognose { + rating: TrachtprognoseRating; + season: string; + reasoning: string[]; + historicalAverageYieldKg: number | null; +} + +interface OpenMeteoDaily { + time: string[]; + temperature_2m_max: (number | null)[]; + temperature_2m_min: (number | null)[]; + precipitation_sum: (number | null)[]; +} + +interface OpenMeteoResponse { + daily?: OpenMeteoDaily; +} + +@Injectable() +export class WeatherService { + constructor(private readonly prisma: PrismaService) {} + + async getWeather(apiaryId: string): Promise { + const { latitude, longitude } = await this.getCoordinates(apiaryId); + return { daily: await this.fetchDailyWeather(latitude, longitude) }; + } + + async getTrachtprognose(apiaryId: string): Promise { + const { latitude, longitude } = await this.getCoordinates(apiaryId); + const daily = await this.fetchDailyWeather(latitude, longitude); + const today = new Date().toISOString().slice(0, 10); + const recentDays = daily + .filter((day) => day.date <= today) + .slice(-RECENT_WINDOW_DAYS); + const historicalAverageYieldKg = + await this.getHistoricalAverageYield(apiaryId); + return this.computeTrachtprognose(recentDays, historicalAverageYieldKg); + } + + private async getCoordinates( + apiaryId: string, + ): Promise<{ latitude: number; longitude: number }> { + const apiary = await this.prisma.apiary.findUnique({ + where: { id: apiaryId }, + select: { latitude: true, longitude: true }, + }); + if (!apiary) { + throw new NotFoundException('Apiary not found'); + } + if (apiary.latitude === null || apiary.longitude === null) { + throw new BadRequestException( + 'Apiary has no coordinates; weather data requires latitude and longitude', + ); + } + return { latitude: apiary.latitude, longitude: apiary.longitude }; + } + + private async fetchDailyWeather( + latitude: number, + longitude: number, + ): Promise { + const url = new URL(OPEN_METEO_FORECAST_URL); + url.searchParams.set('latitude', String(latitude)); + url.searchParams.set('longitude', String(longitude)); + url.searchParams.set( + 'daily', + 'temperature_2m_max,temperature_2m_min,precipitation_sum', + ); + url.searchParams.set('past_days', String(PAST_DAYS)); + url.searchParams.set('forecast_days', String(FORECAST_DAYS)); + url.searchParams.set('timezone', 'auto'); + + const response = await fetch(url); + if (!response.ok) { + throw new BadRequestException('Wetterdaten konnten nicht geladen werden'); + } + const body = (await response.json()) as OpenMeteoResponse; + if (!body.daily) { + return []; + } + const { time, temperature_2m_max, temperature_2m_min, precipitation_sum } = + body.daily; + return time.map((date, index) => ({ + date, + tempMaxC: temperature_2m_max[index] ?? null, + tempMinC: temperature_2m_min[index] ?? null, + precipitationMm: precipitation_sum[index] ?? null, + })); + } + + private getSeason(month: number): { season: string; inBloom: boolean } { + if (month >= 3 && month <= 5) { + return { season: 'Frühtracht', inBloom: true }; + } + if (month >= 6 && month <= 7) { + return { season: 'Sommertracht', inBloom: true }; + } + if (month === 8) { + return { season: 'Spättracht', inBloom: true }; + } + return { season: 'Winterruhe', inBloom: false }; + } + + private async getHistoricalAverageYield( + apiaryId: string, + ): Promise { + const currentMonth = new Date().getMonth() + 1; + const harvests = await this.prisma.harvest.findMany({ + where: { apiaryId }, + select: { date: true, netWeight: true }, + }); + const sameMonthYields = harvests + .filter((harvest) => harvest.date.getMonth() + 1 === currentMonth) + .map((harvest) => harvest.netWeight) + .filter((weight): weight is number => weight !== null); + if (sameMonthYields.length === 0) { + return null; + } + const total = sameMonthYields.reduce((sum, weight) => sum + weight, 0); + return Math.round((total / sameMonthYields.length) * 10) / 10; + } + + private computeTrachtprognose( + recentDays: DailyWeather[], + historicalAverageYieldKg: number | null, + ): Trachtprognose { + const { season, inBloom } = this.getSeason(new Date().getMonth() + 1); + const reasoning: string[] = []; + + if (!inBloom) { + reasoning.push(`Aktuell keine Haupttrachtperiode (${season}).`); + return { + rating: 'KEINE_TRACHT', + season, + reasoning, + historicalAverageYieldKg, + }; + } + + const validTemps = recentDays + .map((day) => day.tempMaxC) + .filter((value): value is number => value !== null); + const avgTempMax = validTemps.length + ? validTemps.reduce((sum, value) => sum + value, 0) / validTemps.length + : null; + const totalPrecipitation = recentDays.reduce( + (sum, day) => sum + (day.precipitationMm ?? 0), + 0, + ); + + let rating: TrachtprognoseRating = 'MAESSIG'; + if (avgTempMax === null) { + reasoning.push('Keine ausreichenden Wetterdaten für eine Einschätzung.'); + } else if ( + avgTempMax >= GOOD_TEMP_THRESHOLD_C && + totalPrecipitation < GOOD_PRECIPITATION_THRESHOLD_MM + ) { + rating = 'GUT'; + reasoning.push( + `Warmes, trockenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) begünstigt Bienenflug.`, + ); + } else if ( + avgTempMax < POOR_TEMP_THRESHOLD_C || + totalPrecipitation > POOR_PRECIPITATION_THRESHOLD_MM + ) { + rating = 'SCHWACH'; + reasoning.push( + `Kühles oder nasses Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag) schränkt Bienenflug ein.`, + ); + } else { + reasoning.push( + `Durchwachsenes Wetter der letzten 7 Tage (Ø ${avgTempMax.toFixed(1)}°C, ${totalPrecipitation.toFixed(0)} mm Niederschlag).`, + ); + } + + reasoning.push(`Saison: ${season}.`); + if (historicalAverageYieldKg !== null) { + reasoning.push( + `Historischer Ernteschnitt für diesen Zeitraum an diesem Standort: ${historicalAverageYieldKg} kg.`, + ); + } + + return { rating, season, reasoning, historicalAverageYieldKg }; + } +} diff --git a/ApiCore-Backend/test/weather.e2e-spec.ts b/ApiCore-Backend/test/weather.e2e-spec.ts new file mode 100644 index 0000000..118cbd1 --- /dev/null +++ b/ApiCore-Backend/test/weather.e2e-spec.ts @@ -0,0 +1,117 @@ +import { INestApplication } from '@nestjs/common'; +import * as bcrypt from 'bcryptjs'; +import request from 'supertest'; +import { App } from 'supertest/types'; +import { PrismaService } from '../src/prisma/prisma.service'; +import { createTestApp } from './utils/create-test-app'; +import { login } from './utils/api'; + +interface DailyWeatherBody { + date: string; + tempMaxC: number | null; + tempMinC: number | null; + precipitationMm: number | null; +} + +interface WeatherBody { + daily: DailyWeatherBody[]; +} + +interface TrachtprognoseBody { + rating: string; + season: string; + reasoning: string[]; + historicalAverageYieldKg: number | null; +} + +describe('Weather (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `weather-e2e-beekeeper-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let beekeeperToken: string; + let apiaryWithCoordsId: string; + let apiaryWithoutCoordsId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Weather E2E Beekeeper', + role: { connect: { key: 'BEEKEEPER' } }, + }, + }); + beekeeperId = beekeeper.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + + const withCoords = await prisma.apiary.create({ + data: { + name: `Weather E2E Stand mit Koordinaten ${Date.now()}`, + latitude: 52.52, + longitude: 13.405, + }, + }); + apiaryWithCoordsId = withCoords.id; + + const withoutCoords = await prisma.apiary.create({ + data: { name: `Weather E2E Stand ohne Koordinaten ${Date.now()}` }, + }); + apiaryWithoutCoordsId = withoutCoords.id; + }); + + afterAll(async () => { + await prisma.apiary.deleteMany({ + where: { id: { in: [apiaryWithCoordsId, apiaryWithoutCoordsId] } }, + }); + await prisma.refreshToken.deleteMany({ where: { userId: beekeeperId } }); + await prisma.auditLog.deleteMany({ where: { userId: beekeeperId } }); + await prisma.user.delete({ where: { id: beekeeperId } }); + await app.close(); + }); + + it('returns real daily weather for an apiary with coordinates', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${apiaryWithCoordsId}/weather`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + + const body = response.body as WeatherBody; + expect(Array.isArray(body.daily)).toBe(true); + expect(body.daily.length).toBeGreaterThan(15); + const today = body.daily[0]; + expect(today.date).toEqual(expect.stringMatching(/^\d{4}-\d{2}-\d{2}$/)); + }, 15000); + + it('returns a Trachtprognose with a valid rating for an apiary with coordinates', async () => { + const response = await request(app.getHttpServer()) + .get(`/api/v1/apiaries/${apiaryWithCoordsId}/trachtprognose`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(200); + + const body = response.body as TrachtprognoseBody; + expect(['GUT', 'MAESSIG', 'SCHWACH', 'KEINE_TRACHT']).toContain( + body.rating, + ); + expect(body.season).toEqual(expect.any(String)); + expect(body.reasoning.length).toBeGreaterThan(0); + }, 15000); + + it('rejects weather requests for an apiary without coordinates', () => { + return request(app.getHttpServer()) + .get(`/api/v1/apiaries/${apiaryWithoutCoordsId}/weather`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .expect(400); + }); + + it('rejects unauthenticated weather requests', () => { + return request(app.getHttpServer()) + .get(`/api/v1/apiaries/${apiaryWithCoordsId}/weather`) + .expect(401); + }); +}); diff --git a/TASKS.md b/TASKS.md index f4b34ae..bb1453b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -290,10 +290,3 @@ Software-seitig umsetzbare Punkte werden gebaut. Punkte, die dedizierte native B - [x] ~~KI-Auswertung von Völkerdaten, automatische Anomalieerkennung, Fotoanalyse~~ – nicht umsetzbar in diesem Kontext: erfordert dediziertes ML-Modelltraining/-Betrieb, keine seriöse Umsetzung als einfache Heuristik - [x] ~~Spracherfassung am Bienenstand, automatische Zusammenfassung einer Durchsicht~~ – nicht umsetzbar in diesem Kontext: erfordert Spracherkennungs-/LLM-Infrastruktur über das bestehende Stack hinaus - [x] ~~Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren)~~ – nicht umsetzbar in diesem Kontext: erfordert physische IoT-Hardware -- [ ] KI-Auswertung von Völkerdaten / automatische Anomalieerkennung -- [ ] Fotoanalyse -- [ ] Spracherfassung am Bienenstand -- [ ] Automatische Zusammenfassung einer Durchsicht -- [ ] Sensorintegration (Stockwaagen, Temperatur-/Feuchtesensoren) -- [x] Öffentliche Herkunftsseiten für Honigchargen (Wabenhain-Integration, Abschnitt 27) -- [x] Kunden-QR-Code auf Honiggläsern